diff --git a/.github/workflows/db_migrations.yml b/.github/workflows/db_migrations.yml index ff8ad59a..2b80c3a1 100644 --- a/.github/workflows/db_migrations.yml +++ b/.github/workflows/db_migrations.yml @@ -2,6 +2,8 @@ name: Test Database Migrations on: push: + branches: + - main paths: - "server/migrations/**" - "server/reflector/db/**" @@ -17,6 +19,9 @@ on: jobs: test-migrations: runs-on: ubuntu-latest + concurrency: + group: db-ubuntu-latest-${{ github.ref }} + cancel-in-progress: true services: postgres: image: postgres:17 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 16e84df6..fe33dd84 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to Amazon ECS +name: Build container/push to container registry on: [workflow_dispatch] diff --git a/.github/workflows/docker-frontend.yml b/.github/workflows/docker-frontend.yml new file mode 100644 index 00000000..ea861782 --- /dev/null +++ b/.github/workflows/docker-frontend.yml @@ -0,0 +1,57 @@ +name: Build and Push Frontend Docker Image + +on: + push: + branches: + - main + paths: + - 'www/**' + - '.github/workflows/docker-frontend.yml' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}-frontend + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./www + file: ./www/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 \ No newline at end of file diff --git a/.github/workflows/test_next_server.yml b/.github/workflows/test_next_server.yml new file mode 100644 index 00000000..892566d6 --- /dev/null +++ b/.github/workflows/test_next_server.yml @@ -0,0 +1,45 @@ +name: Test Next Server + +on: + pull_request: + paths: + - "www/**" + push: + branches: + - main + paths: + - "www/**" + +jobs: + test-next-server: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./www + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Setup Node.js cache + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: './www/pnpm-lock.yaml' + + - name: Install dependencies + run: pnpm install + + - name: Run tests + run: pnpm test \ No newline at end of file diff --git a/.github/workflows/test_server.yml b/.github/workflows/test_server.yml index 262e0e05..f03d020e 100644 --- a/.github/workflows/test_server.yml +++ b/.github/workflows/test_server.yml @@ -5,12 +5,17 @@ on: paths: - "server/**" push: + branches: + - main paths: - "server/**" jobs: pytest: runs-on: ubuntu-latest + concurrency: + group: pytest-${{ github.ref }} + cancel-in-progress: true services: redis: image: redis:6 @@ -30,6 +35,9 @@ jobs: docker-amd64: runs-on: linux-amd64 + concurrency: + group: docker-amd64-${{ github.ref }} + cancel-in-progress: true steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx @@ -45,6 +53,9 @@ jobs: docker-arm64: runs-on: linux-arm64 + concurrency: + group: docker-arm64-${{ github.ref }} + cancel-in-progress: true steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx diff --git a/.gitignore b/.gitignore index 29d56f25..f3249991 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ server/test.sqlite CLAUDE.local.md www/.env.development www/.env.production +.playwright-mcp diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e7b2099..083f5b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,170 @@ # Changelog +## [0.18.0](https://github.com/Monadical-SAS/reflector/compare/v0.17.0...v0.18.0) (2025-11-14) + + +### Features + +* daily QOL: participants dictionary ([#721](https://github.com/Monadical-SAS/reflector/issues/721)) ([b20cad7](https://github.com/Monadical-SAS/reflector/commit/b20cad76e69fb6a76405af299a005f1ddcf60eae)) + + +### Bug Fixes + +* add proccessing page to file upload and reprocessing ([#650](https://github.com/Monadical-SAS/reflector/issues/650)) ([28a7258](https://github.com/Monadical-SAS/reflector/commit/28a7258e45317b78e60e6397be2bc503647eaace)) +* copy transcript ([#674](https://github.com/Monadical-SAS/reflector/issues/674)) ([a9a4f32](https://github.com/Monadical-SAS/reflector/commit/a9a4f32324f66c838e081eee42bb9502f38c1db1)) + +## [0.17.0](https://github.com/Monadical-SAS/reflector/compare/v0.16.0...v0.17.0) (2025-11-13) + + +### Features + +* add API key management UI ([#716](https://github.com/Monadical-SAS/reflector/issues/716)) ([372202b](https://github.com/Monadical-SAS/reflector/commit/372202b0e1a86823900b0aa77be1bfbc2893d8a1)) +* daily.co support as alternative to whereby ([#691](https://github.com/Monadical-SAS/reflector/issues/691)) ([1473fd8](https://github.com/Monadical-SAS/reflector/commit/1473fd82dc472c394cbaa2987212ad662a74bcac)) + +## [0.16.0](https://github.com/Monadical-SAS/reflector/compare/v0.15.0...v0.16.0) (2025-10-24) + + +### Features + +* search date filter ([#710](https://github.com/Monadical-SAS/reflector/issues/710)) ([962c40e](https://github.com/Monadical-SAS/reflector/commit/962c40e2b6428ac42fd10aea926782d7a6f3f902)) + +## [0.15.0](https://github.com/Monadical-SAS/reflector/compare/v0.14.0...v0.15.0) (2025-10-20) + + +### Features + +* api tokens ([#705](https://github.com/Monadical-SAS/reflector/issues/705)) ([9a258ab](https://github.com/Monadical-SAS/reflector/commit/9a258abc0209b0ac3799532a507ea6a9125d703a)) + +## [0.14.0](https://github.com/Monadical-SAS/reflector/compare/v0.13.1...v0.14.0) (2025-10-08) + + +### Features + +* Add calendar event data to transcript webhook payload ([#689](https://github.com/Monadical-SAS/reflector/issues/689)) ([5f6910e](https://github.com/Monadical-SAS/reflector/commit/5f6910e5131b7f28f86c9ecdcc57fed8412ee3cd)) +* container build for www / github ([#672](https://github.com/Monadical-SAS/reflector/issues/672)) ([969bd84](https://github.com/Monadical-SAS/reflector/commit/969bd84fcc14851d1a101412a0ba115f1b7cde82)) +* docker-compose for production frontend ([#664](https://github.com/Monadical-SAS/reflector/issues/664)) ([5bf64b5](https://github.com/Monadical-SAS/reflector/commit/5bf64b5a41f64535e22849b4bb11734d4dbb4aae)) + + +### Bug Fixes + +* restore feature boolean logic ([#671](https://github.com/Monadical-SAS/reflector/issues/671)) ([3660884](https://github.com/Monadical-SAS/reflector/commit/36608849ec64e953e3be456172502762e3c33df9)) +* security review ([#656](https://github.com/Monadical-SAS/reflector/issues/656)) ([5d98754](https://github.com/Monadical-SAS/reflector/commit/5d98754305c6c540dd194dda268544f6d88bfaf8)) +* update transcript list on reprocess ([#676](https://github.com/Monadical-SAS/reflector/issues/676)) ([9a71af1](https://github.com/Monadical-SAS/reflector/commit/9a71af145ee9b833078c78d0c684590ab12e9f0e)) +* upgrade nemo toolkit ([#678](https://github.com/Monadical-SAS/reflector/issues/678)) ([eef6dc3](https://github.com/Monadical-SAS/reflector/commit/eef6dc39037329b65804297786d852dddb0557f9)) + +## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22) + + +### Bug Fixes + +* TypeError on not all arguments converted during string formatting in logger ([#667](https://github.com/Monadical-SAS/reflector/issues/667)) ([565a629](https://github.com/Monadical-SAS/reflector/commit/565a62900f5a02fc946b68f9269a42190ed70ab6)) + +## [0.13.0](https://github.com/Monadical-SAS/reflector/compare/v0.12.1...v0.13.0) (2025-09-19) + + +### Features + +* room form edit with enter ([#662](https://github.com/Monadical-SAS/reflector/issues/662)) ([47716f6](https://github.com/Monadical-SAS/reflector/commit/47716f6e5ddee952609d2fa0ffabdfa865286796)) + + +### Bug Fixes + +* invalid cleanup call ([#660](https://github.com/Monadical-SAS/reflector/issues/660)) ([0abcebf](https://github.com/Monadical-SAS/reflector/commit/0abcebfc9491f87f605f21faa3e53996fafedd9a)) + +## [0.12.1](https://github.com/Monadical-SAS/reflector/compare/v0.12.0...v0.12.1) (2025-09-17) + + +### Bug Fixes + +* production blocked because having existing meeting with room_id null ([#657](https://github.com/Monadical-SAS/reflector/issues/657)) ([870e860](https://github.com/Monadical-SAS/reflector/commit/870e8605171a27155a9cbee215eeccb9a8d6c0a2)) + +## [0.12.0](https://github.com/Monadical-SAS/reflector/compare/v0.11.0...v0.12.0) (2025-09-17) + + +### Features + +* calendar integration ([#608](https://github.com/Monadical-SAS/reflector/issues/608)) ([6f680b5](https://github.com/Monadical-SAS/reflector/commit/6f680b57954c688882c4ed49f40f161c52a00a24)) +* self-hosted gpu api ([#636](https://github.com/Monadical-SAS/reflector/issues/636)) ([ab859d6](https://github.com/Monadical-SAS/reflector/commit/ab859d65a6bded904133a163a081a651b3938d42)) + + +### Bug Fixes + +* ignore player hotkeys for text inputs ([#646](https://github.com/Monadical-SAS/reflector/issues/646)) ([fa049e8](https://github.com/Monadical-SAS/reflector/commit/fa049e8d068190ce7ea015fd9fcccb8543f54a3f)) + +## [0.11.0](https://github.com/Monadical-SAS/reflector/compare/v0.10.0...v0.11.0) (2025-09-16) + + +### Features + +* remove profanity filter that was there for conference ([#652](https://github.com/Monadical-SAS/reflector/issues/652)) ([b42f7cf](https://github.com/Monadical-SAS/reflector/commit/b42f7cfc606783afcee792590efcc78b507468ab)) + + +### Bug Fixes + +* zulip and consent handler on the file pipeline ([#645](https://github.com/Monadical-SAS/reflector/issues/645)) ([5f143fe](https://github.com/Monadical-SAS/reflector/commit/5f143fe3640875dcb56c26694254a93189281d17)) +* zulip stream and topic selection in share dialog ([#644](https://github.com/Monadical-SAS/reflector/issues/644)) ([c546e69](https://github.com/Monadical-SAS/reflector/commit/c546e69739e68bb74fbc877eb62609928e5b8de6)) + +## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11) + + +### Features + +* replace nextjs-config with environment variables ([#632](https://github.com/Monadical-SAS/reflector/issues/632)) ([369ecdf](https://github.com/Monadical-SAS/reflector/commit/369ecdff13f3862d926a9c0b87df52c9d94c4dde)) + + +### Bug Fixes + +* anonymous users transcript permissions ([#621](https://github.com/Monadical-SAS/reflector/issues/621)) ([f81fe99](https://github.com/Monadical-SAS/reflector/commit/f81fe9948a9237b3e0001b2d8ca84f54d76878f9)) +* auth post ([#624](https://github.com/Monadical-SAS/reflector/issues/624)) ([cde99ca](https://github.com/Monadical-SAS/reflector/commit/cde99ca2716f84ba26798f289047732f0448742e)) +* auth post ([#626](https://github.com/Monadical-SAS/reflector/issues/626)) ([3b85ff3](https://github.com/Monadical-SAS/reflector/commit/3b85ff3bdf4fb053b103070646811bc990c0e70a)) +* auth post ([#627](https://github.com/Monadical-SAS/reflector/issues/627)) ([962038e](https://github.com/Monadical-SAS/reflector/commit/962038ee3f2a555dc3c03856be0e4409456e0996)) +* missing follow_redirects=True on modal endpoint ([#630](https://github.com/Monadical-SAS/reflector/issues/630)) ([fc363bd](https://github.com/Monadical-SAS/reflector/commit/fc363bd49b17b075e64f9186e5e0185abc325ea7)) +* sync backend and frontend token refresh logic ([#614](https://github.com/Monadical-SAS/reflector/issues/614)) ([5a5b323](https://github.com/Monadical-SAS/reflector/commit/5a5b3233820df9536da75e87ce6184a983d4713a)) + +## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06) + + +### Features + +* frontend openapi react query ([#606](https://github.com/Monadical-SAS/reflector/issues/606)) ([c4d2825](https://github.com/Monadical-SAS/reflector/commit/c4d2825c81f81ad8835629fbf6ea8c7383f8c31b)) + + +### Bug Fixes + +* align whisper transcriber api with parakeet ([#602](https://github.com/Monadical-SAS/reflector/issues/602)) ([0663700](https://github.com/Monadical-SAS/reflector/commit/0663700a615a4af69a03c96c410f049e23ec9443)) +* kv use tls explicit ([#610](https://github.com/Monadical-SAS/reflector/issues/610)) ([08d88ec](https://github.com/Monadical-SAS/reflector/commit/08d88ec349f38b0d13e0fa4cb73486c8dfd31836)) +* source kind for file processing ([#601](https://github.com/Monadical-SAS/reflector/issues/601)) ([dc82f8b](https://github.com/Monadical-SAS/reflector/commit/dc82f8bb3bdf3ab3d4088e592a30fd63907319e1)) +* token refresh locking ([#613](https://github.com/Monadical-SAS/reflector/issues/613)) ([7f5a4c9](https://github.com/Monadical-SAS/reflector/commit/7f5a4c9ddc7fd098860c8bdda2ca3b57f63ded2f)) + +## [0.8.2](https://github.com/Monadical-SAS/reflector/compare/v0.8.1...v0.8.2) (2025-08-29) + + +### Bug Fixes + +* search-logspam ([#593](https://github.com/Monadical-SAS/reflector/issues/593)) ([695d1a9](https://github.com/Monadical-SAS/reflector/commit/695d1a957d4cd862753049f9beed88836cabd5ab)) + +## [0.8.1](https://github.com/Monadical-SAS/reflector/compare/v0.8.0...v0.8.1) (2025-08-29) + + +### Bug Fixes + +* make webhook secret/url allowing null ([#590](https://github.com/Monadical-SAS/reflector/issues/590)) ([84a3812](https://github.com/Monadical-SAS/reflector/commit/84a381220bc606231d08d6f71d4babc818fa3c75)) + +## [0.8.0](https://github.com/Monadical-SAS/reflector/compare/v0.7.3...v0.8.0) (2025-08-29) + + +### Features + +* **cleanup:** add automatic data retention for public instances ([#574](https://github.com/Monadical-SAS/reflector/issues/574)) ([6f0c7c1](https://github.com/Monadical-SAS/reflector/commit/6f0c7c1a5e751713366886c8e764c2009e12ba72)) +* **rooms:** add webhook for transcript completion ([#578](https://github.com/Monadical-SAS/reflector/issues/578)) ([88ed7cf](https://github.com/Monadical-SAS/reflector/commit/88ed7cfa7804794b9b54cad4c3facc8a98cf85fd)) + + +### Bug Fixes + +* file pipeline status reporting and websocket updates ([#589](https://github.com/Monadical-SAS/reflector/issues/589)) ([9dfd769](https://github.com/Monadical-SAS/reflector/commit/9dfd76996f851cc52be54feea078adbc0816dc57)) +* Igor/evaluation ([#575](https://github.com/Monadical-SAS/reflector/issues/575)) ([124ce03](https://github.com/Monadical-SAS/reflector/commit/124ce03bf86044c18313d27228a25da4bc20c9c5)) +* optimize parakeet transcription batching algorithm ([#577](https://github.com/Monadical-SAS/reflector/issues/577)) ([7030e0f](https://github.com/Monadical-SAS/reflector/commit/7030e0f23649a8cf6c1eb6d5889684a41ce849ec)) + ## [0.7.3](https://github.com/Monadical-SAS/reflector/compare/v0.7.2...v0.7.3) (2025-08-22) diff --git a/CLAUDE.md b/CLAUDE.md index 14c58e42..202fba4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,6 @@ pnpm install # Copy configuration templates cp .env_template .env -cp config-template.ts config.ts ``` **Development:** @@ -152,7 +151,7 @@ All endpoints prefixed `/v1/`: **Frontend** (`www/.env`): - `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration -- `NEXT_PUBLIC_REFLECTOR_API_URL` - Backend API endpoint +- `REFLECTOR_API_URL` - Backend API endpoint - `REFLECTOR_DOMAIN_CONFIG` - Feature flags and domain settings ## Testing Strategy diff --git a/README.md b/README.md index 497dd5b5..d6bdb86e 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,10 @@ Start with `cd www`. ```bash pnpm install -cp .env_template .env -cp config-template.ts config.ts +cp .env.example .env ``` -Then, fill in the environment variables in `.env` and the configuration in `config.ts` as needed. If you are unsure on how to proceed, ask in Zulip. +Then, fill in the environment variables in `.env` as needed. If you are unsure on how to proceed, ask in Zulip. **Run in development mode** @@ -168,3 +167,41 @@ You can manually process an audio file by calling the process tool: ```bash uv run python -m reflector.tools.process path/to/audio.wav ``` + +## Build-time env variables + +Next.js projects are more used to NEXT_PUBLIC_ prefixed buildtime vars. We don't have those for the reason we need to serve a ccustomizable prebuild docker container. + +Instead, all the variables are runtime. Variables needed to the frontend are served to the frontend app at initial render. + +It also means there's no static prebuild and no static files to serve for js/html. + +## Feature Flags + +Reflector uses environment variable-based feature flags to control application functionality. These flags allow you to enable or disable features without code changes. + +### Available Feature Flags + +| Feature Flag | Environment Variable | +|-------------|---------------------| +| `requireLogin` | `FEATURE_REQUIRE_LOGIN` | +| `privacy` | `FEATURE_PRIVACY` | +| `browse` | `FEATURE_BROWSE` | +| `sendToZulip` | `FEATURE_SEND_TO_ZULIP` | +| `rooms` | `FEATURE_ROOMS` | + +### Setting Feature Flags + +Feature flags are controlled via environment variables using the pattern `FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name. + +**Examples:** +```bash +# Enable user authentication requirement +FEATURE_REQUIRE_LOGIN=true + +# Disable browse functionality +FEATURE_BROWSE=false + +# Enable Zulip integration +FEATURE_SEND_TO_ZULIP=true +``` diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..9b032e40 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,39 @@ +# Production Docker Compose configuration for Frontend +# Usage: docker compose -f docker-compose.prod.yml up -d + +services: + web: + build: + context: ./www + dockerfile: Dockerfile + image: reflector-frontend:latest + environment: + - KV_URL=${KV_URL:-redis://redis:6379} + - SITE_URL=${SITE_URL} + - API_URL=${API_URL} + - WEBSOCKET_URL=${WEBSOCKET_URL} + - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000} + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-changeme-in-production} + - AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER} + - AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID} + - AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET} + - AUTHENTIK_REFRESH_TOKEN_URL=${AUTHENTIK_REFRESH_TOKEN_URL} + - SENTRY_DSN=${SENTRY_DSN} + - SENTRY_IGNORE_API_RESOLUTION_ERROR=${SENTRY_IGNORE_API_RESOLUTION_ERROR:-1} + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:7.2-alpine + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 3s + retries: 3 + volumes: + - redis_data:/data + +volumes: + redis_data: \ No newline at end of file diff --git a/compose.yml b/docker-compose.yml similarity index 89% rename from compose.yml rename to docker-compose.yml index 492c7b8c..2fd4543d 100644 --- a/compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: - 1250:1250 volumes: - ./server/:/app/ + - /app/.venv env_file: - ./server/.env environment: @@ -16,6 +17,7 @@ services: context: server volumes: - ./server/:/app/ + - /app/.venv env_file: - ./server/.env environment: @@ -26,6 +28,7 @@ services: context: server volumes: - ./server/:/app/ + - /app/.venv env_file: - ./server/.env environment: @@ -36,7 +39,7 @@ services: ports: - 6379:6379 web: - image: node:18 + image: node:22-alpine ports: - "3000:3000" command: sh -c "corepack enable && pnpm install && pnpm dev" @@ -47,6 +50,8 @@ services: - /app/node_modules env_file: - ./www/.env.local + environment: + - NODE_ENV=development postgres: image: postgres:17 diff --git a/gpu/modal_deployments/.gitignore b/gpu/modal_deployments/.gitignore new file mode 100644 index 00000000..734bd3b2 --- /dev/null +++ b/gpu/modal_deployments/.gitignore @@ -0,0 +1,33 @@ +# OS / Editor +.DS_Store +.vscode/ +.idea/ + +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Logs +*.log + +# Env and secrets +.env +.env.* +*.env +*.secret + +# Build / dist +build/ +dist/ +.eggs/ +*.egg-info/ + +# Coverage / test +.pytest_cache/ +.coverage* +htmlcov/ + +# Modal local state (if any) +modal_mounts/ +.modal_cache/ diff --git a/server/gpu/modal_deployments/README.md b/gpu/modal_deployments/README.md similarity index 100% rename from server/gpu/modal_deployments/README.md rename to gpu/modal_deployments/README.md diff --git a/server/gpu/modal_deployments/reflector_diarizer.py b/gpu/modal_deployments/reflector_diarizer.py similarity index 100% rename from server/gpu/modal_deployments/reflector_diarizer.py rename to gpu/modal_deployments/reflector_diarizer.py diff --git a/gpu/modal_deployments/reflector_transcriber.py b/gpu/modal_deployments/reflector_transcriber.py new file mode 100644 index 00000000..3be25542 --- /dev/null +++ b/gpu/modal_deployments/reflector_transcriber.py @@ -0,0 +1,608 @@ +import os +import sys +import threading +import uuid +from typing import Generator, Mapping, NamedTuple, NewType, TypedDict +from urllib.parse import urlparse + +import modal + +MODEL_NAME = "large-v2" +MODEL_COMPUTE_TYPE: str = "float16" +MODEL_NUM_WORKERS: int = 1 +MINUTES = 60 # seconds +SAMPLERATE = 16000 +UPLOADS_PATH = "/uploads" +CACHE_PATH = "/models" +SUPPORTED_FILE_EXTENSIONS = ["mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"] +VAD_CONFIG = { + "batch_max_duration": 30.0, + "silence_padding": 0.5, + "window_size": 512, +} + + +WhisperUniqFilename = NewType("WhisperUniqFilename", str) +AudioFileExtension = NewType("AudioFileExtension", str) + +app = modal.App("reflector-transcriber") + +model_cache = modal.Volume.from_name("models", create_if_missing=True) +upload_volume = modal.Volume.from_name("whisper-uploads", create_if_missing=True) + + +class TimeSegment(NamedTuple): + """Represents a time segment with start and end times.""" + + start: float + end: float + + +class AudioSegment(NamedTuple): + """Represents an audio segment with timing and audio data.""" + + start: float + end: float + audio: any + + +class TranscriptResult(NamedTuple): + """Represents a transcription result with text and word timings.""" + + text: str + words: list["WordTiming"] + + +class WordTiming(TypedDict): + """Represents a word with its timing information.""" + + word: str + start: float + end: float + + +def download_model(): + from faster_whisper import download_model + + model_cache.reload() + + download_model(MODEL_NAME, cache_dir=CACHE_PATH) + + model_cache.commit() + + +image = ( + modal.Image.debian_slim(python_version="3.12") + .env( + { + "HF_HUB_ENABLE_HF_TRANSFER": "1", + "LD_LIBRARY_PATH": ( + "/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib/:" + "/opt/conda/lib/python3.12/site-packages/nvidia/cublas/lib/" + ), + } + ) + .apt_install("ffmpeg") + .pip_install( + "huggingface_hub==0.27.1", + "hf-transfer==0.1.9", + "torch==2.5.1", + "faster-whisper==1.1.1", + "fastapi==0.115.12", + "requests", + "librosa==0.10.1", + "numpy<2", + "silero-vad==5.1.0", + ) + .run_function(download_model, volumes={CACHE_PATH: model_cache}) +) + + +def detect_audio_format(url: str, headers: Mapping[str, str]) -> AudioFileExtension: + parsed_url = urlparse(url) + url_path = parsed_url.path + + for ext in SUPPORTED_FILE_EXTENSIONS: + if url_path.lower().endswith(f".{ext}"): + return AudioFileExtension(ext) + + content_type = headers.get("content-type", "").lower() + if "audio/mpeg" in content_type or "audio/mp3" in content_type: + return AudioFileExtension("mp3") + if "audio/wav" in content_type: + return AudioFileExtension("wav") + if "audio/mp4" in content_type: + return AudioFileExtension("mp4") + + raise ValueError( + f"Unsupported audio format for URL: {url}. " + f"Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}" + ) + + +def download_audio_to_volume( + audio_file_url: str, +) -> tuple[WhisperUniqFilename, AudioFileExtension]: + import requests + from fastapi import HTTPException + + response = requests.head(audio_file_url, allow_redirects=True) + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Audio file not found") + + response = requests.get(audio_file_url, allow_redirects=True) + response.raise_for_status() + + audio_suffix = detect_audio_format(audio_file_url, response.headers) + unique_filename = WhisperUniqFilename(f"{uuid.uuid4()}.{audio_suffix}") + file_path = f"{UPLOADS_PATH}/{unique_filename}" + + with open(file_path, "wb") as f: + f.write(response.content) + + upload_volume.commit() + return unique_filename, audio_suffix + + +def pad_audio(audio_array, sample_rate: int = SAMPLERATE): + """Add 0.5s of silence if audio is shorter than the silence_padding window. + + Whisper does not require this strictly, but aligning behavior with Parakeet + avoids edge-case crashes on extremely short inputs and makes comparisons easier. + """ + import numpy as np + + audio_duration = len(audio_array) / sample_rate + if audio_duration < VAD_CONFIG["silence_padding"]: + silence_samples = int(sample_rate * VAD_CONFIG["silence_padding"]) + silence = np.zeros(silence_samples, dtype=np.float32) + return np.concatenate([audio_array, silence]) + return audio_array + + +@app.cls( + gpu="A10G", + timeout=5 * MINUTES, + scaledown_window=5 * MINUTES, + image=image, + volumes={CACHE_PATH: model_cache, UPLOADS_PATH: upload_volume}, +) +@modal.concurrent(max_inputs=10) +class TranscriberWhisperLive: + """Live transcriber class for small audio segments (A10G). + + Mirrors the Parakeet live class API but uses Faster-Whisper under the hood. + """ + + @modal.enter() + def enter(self): + import faster_whisper + import torch + + self.lock = threading.Lock() + self.use_gpu = torch.cuda.is_available() + self.device = "cuda" if self.use_gpu else "cpu" + self.model = faster_whisper.WhisperModel( + MODEL_NAME, + device=self.device, + compute_type=MODEL_COMPUTE_TYPE, + num_workers=MODEL_NUM_WORKERS, + download_root=CACHE_PATH, + local_files_only=True, + ) + print(f"Model is on device: {self.device}") + + @modal.method() + def transcribe_segment( + self, + filename: str, + language: str = "en", + ): + """Transcribe a single uploaded audio file by filename.""" + upload_volume.reload() + + file_path = f"{UPLOADS_PATH}/{filename}" + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + with self.lock: + with NoStdStreams(): + segments, _ = self.model.transcribe( + file_path, + language=language, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": 500}, + ) + + segments = list(segments) + text = "".join(segment.text for segment in segments).strip() + words = [ + { + "word": word.word, + "start": round(float(word.start), 2), + "end": round(float(word.end), 2), + } + for segment in segments + for word in segment.words + ] + + return {"text": text, "words": words} + + @modal.method() + def transcribe_batch( + self, + filenames: list[str], + language: str = "en", + ): + """Transcribe multiple uploaded audio files and return per-file results.""" + upload_volume.reload() + + results = [] + for filename in filenames: + file_path = f"{UPLOADS_PATH}/{filename}" + if not os.path.exists(file_path): + raise FileNotFoundError(f"Batch file not found: {file_path}") + + with self.lock: + with NoStdStreams(): + segments, _ = self.model.transcribe( + file_path, + language=language, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": 500}, + ) + + segments = list(segments) + text = "".join(seg.text for seg in segments).strip() + words = [ + { + "word": w.word, + "start": round(float(w.start), 2), + "end": round(float(w.end), 2), + } + for seg in segments + for w in seg.words + ] + + results.append( + { + "filename": filename, + "text": text, + "words": words, + } + ) + + return results + + +@app.cls( + gpu="L40S", + timeout=15 * MINUTES, + image=image, + volumes={CACHE_PATH: model_cache, UPLOADS_PATH: upload_volume}, +) +class TranscriberWhisperFile: + """File transcriber for larger/longer audio, using VAD-driven batching (L40S).""" + + @modal.enter() + def enter(self): + import faster_whisper + import torch + from silero_vad import load_silero_vad + + self.lock = threading.Lock() + self.use_gpu = torch.cuda.is_available() + self.device = "cuda" if self.use_gpu else "cpu" + self.model = faster_whisper.WhisperModel( + MODEL_NAME, + device=self.device, + compute_type=MODEL_COMPUTE_TYPE, + num_workers=MODEL_NUM_WORKERS, + download_root=CACHE_PATH, + local_files_only=True, + ) + self.vad_model = load_silero_vad(onnx=False) + + @modal.method() + def transcribe_segment( + self, filename: str, timestamp_offset: float = 0.0, language: str = "en" + ): + import librosa + import numpy as np + from silero_vad import VADIterator + + def vad_segments( + audio_array, + sample_rate: int = SAMPLERATE, + window_size: int = VAD_CONFIG["window_size"], + ) -> Generator[TimeSegment, None, None]: + """Generate speech segments as TimeSegment using Silero VAD.""" + iterator = VADIterator(self.vad_model, sampling_rate=sample_rate) + start = None + for i in range(0, len(audio_array), window_size): + chunk = audio_array[i : i + window_size] + if len(chunk) < window_size: + chunk = np.pad( + chunk, (0, window_size - len(chunk)), mode="constant" + ) + speech = iterator(chunk) + if not speech: + continue + if "start" in speech: + start = speech["start"] + continue + if "end" in speech and start is not None: + end = speech["end"] + yield TimeSegment( + start / float(SAMPLERATE), end / float(SAMPLERATE) + ) + start = None + iterator.reset_states() + + upload_volume.reload() + file_path = f"{UPLOADS_PATH}/{filename}" + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + audio_array, _sr = librosa.load(file_path, sr=SAMPLERATE, mono=True) + + # Batch segments up to ~30s windows by merging contiguous VAD segments + merged_batches: list[TimeSegment] = [] + batch_start = None + batch_end = None + max_duration = VAD_CONFIG["batch_max_duration"] + for segment in vad_segments(audio_array): + seg_start, seg_end = segment.start, segment.end + if batch_start is None: + batch_start, batch_end = seg_start, seg_end + continue + if seg_end - batch_start <= max_duration: + batch_end = seg_end + else: + merged_batches.append(TimeSegment(batch_start, batch_end)) + batch_start, batch_end = seg_start, seg_end + if batch_start is not None and batch_end is not None: + merged_batches.append(TimeSegment(batch_start, batch_end)) + + all_text = [] + all_words = [] + + for segment in merged_batches: + start_time, end_time = segment.start, segment.end + s_idx = int(start_time * SAMPLERATE) + e_idx = int(end_time * SAMPLERATE) + segment = audio_array[s_idx:e_idx] + segment = pad_audio(segment, SAMPLERATE) + + with self.lock: + segments, _ = self.model.transcribe( + segment, + language=language, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": 500}, + ) + + segments = list(segments) + text = "".join(seg.text for seg in segments).strip() + words = [ + { + "word": w.word, + "start": round(float(w.start) + start_time + timestamp_offset, 2), + "end": round(float(w.end) + start_time + timestamp_offset, 2), + } + for seg in segments + for w in seg.words + ] + if text: + all_text.append(text) + all_words.extend(words) + + return {"text": " ".join(all_text), "words": all_words} + + +def detect_audio_format(url: str, headers: dict) -> str: + from urllib.parse import urlparse + + from fastapi import HTTPException + + url_path = urlparse(url).path + for ext in SUPPORTED_FILE_EXTENSIONS: + if url_path.lower().endswith(f".{ext}"): + return ext + + content_type = headers.get("content-type", "").lower() + if "audio/mpeg" in content_type or "audio/mp3" in content_type: + return "mp3" + if "audio/wav" in content_type: + return "wav" + if "audio/mp4" in content_type: + return "mp4" + + raise HTTPException( + status_code=400, + detail=( + f"Unsupported audio format for URL. Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}" + ), + ) + + +def download_audio_to_volume(audio_file_url: str) -> tuple[str, str]: + import requests + from fastapi import HTTPException + + response = requests.head(audio_file_url, allow_redirects=True) + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Audio file not found") + + response = requests.get(audio_file_url, allow_redirects=True) + response.raise_for_status() + + audio_suffix = detect_audio_format(audio_file_url, response.headers) + unique_filename = f"{uuid.uuid4()}.{audio_suffix}" + file_path = f"{UPLOADS_PATH}/{unique_filename}" + + with open(file_path, "wb") as f: + f.write(response.content) + + upload_volume.commit() + return unique_filename, audio_suffix + + +@app.function( + scaledown_window=60, + timeout=600, + secrets=[ + modal.Secret.from_name("reflector-gpu"), + ], + volumes={CACHE_PATH: model_cache, UPLOADS_PATH: upload_volume}, + image=image, +) +@modal.concurrent(max_inputs=40) +@modal.asgi_app() +def web(): + from fastapi import ( + Body, + Depends, + FastAPI, + Form, + HTTPException, + UploadFile, + status, + ) + from fastapi.security import OAuth2PasswordBearer + + transcriber_live = TranscriberWhisperLive() + transcriber_file = TranscriberWhisperFile() + + app = FastAPI() + + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + def apikey_auth(apikey: str = Depends(oauth2_scheme)): + if apikey == os.environ["REFLECTOR_GPU_APIKEY"]: + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + class TranscriptResponse(dict): + pass + + @app.post("/v1/audio/transcriptions", dependencies=[Depends(apikey_auth)]) + def transcribe( + file: UploadFile = None, + files: list[UploadFile] | None = None, + model: str = Form(MODEL_NAME), + language: str = Form("en"), + batch: bool = Form(False), + ): + if not file and not files: + raise HTTPException( + status_code=400, detail="Either 'file' or 'files' parameter is required" + ) + if batch and not files: + raise HTTPException( + status_code=400, detail="Batch transcription requires 'files'" + ) + + upload_files = [file] if file else files + + uploaded_filenames: list[str] = [] + for upload_file in upload_files: + audio_suffix = upload_file.filename.split(".")[-1] + if audio_suffix not in SUPPORTED_FILE_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported audio format. Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}" + ), + ) + + unique_filename = f"{uuid.uuid4()}.{audio_suffix}" + file_path = f"{UPLOADS_PATH}/{unique_filename}" + with open(file_path, "wb") as f: + content = upload_file.file.read() + f.write(content) + uploaded_filenames.append(unique_filename) + + upload_volume.commit() + + try: + if batch and len(upload_files) > 1: + func = transcriber_live.transcribe_batch.spawn( + filenames=uploaded_filenames, + language=language, + ) + results = func.get() + return {"results": results} + + results = [] + for filename in uploaded_filenames: + func = transcriber_live.transcribe_segment.spawn( + filename=filename, + language=language, + ) + result = func.get() + result["filename"] = filename + results.append(result) + + return {"results": results} if len(results) > 1 else results[0] + finally: + for filename in uploaded_filenames: + try: + file_path = f"{UPLOADS_PATH}/{filename}" + os.remove(file_path) + except Exception: + pass + upload_volume.commit() + + @app.post("/v1/audio/transcriptions-from-url", dependencies=[Depends(apikey_auth)]) + def transcribe_from_url( + audio_file_url: str = Body( + ..., description="URL of the audio file to transcribe" + ), + model: str = Body(MODEL_NAME), + language: str = Body("en"), + timestamp_offset: float = Body(0.0), + ): + unique_filename, _audio_suffix = download_audio_to_volume(audio_file_url) + try: + func = transcriber_file.transcribe_segment.spawn( + filename=unique_filename, + timestamp_offset=timestamp_offset, + language=language, + ) + result = func.get() + return result + finally: + try: + file_path = f"{UPLOADS_PATH}/{unique_filename}" + os.remove(file_path) + upload_volume.commit() + except Exception: + pass + + return app + + +class NoStdStreams: + def __init__(self): + self.devnull = open(os.devnull, "w") + + def __enter__(self): + self._stdout, self._stderr = sys.stdout, sys.stderr + self._stdout.flush() + self._stderr.flush() + sys.stdout, sys.stderr = self.devnull, self.devnull + + def __exit__(self, exc_type, exc_value, traceback): + sys.stdout, sys.stderr = self._stdout, self._stderr + self.devnull.close() diff --git a/server/gpu/modal_deployments/reflector_transcriber_parakeet.py b/gpu/modal_deployments/reflector_transcriber_parakeet.py similarity index 71% rename from server/gpu/modal_deployments/reflector_transcriber_parakeet.py rename to gpu/modal_deployments/reflector_transcriber_parakeet.py index 97e150e3..5f326b77 100644 --- a/server/gpu/modal_deployments/reflector_transcriber_parakeet.py +++ b/gpu/modal_deployments/reflector_transcriber_parakeet.py @@ -3,7 +3,7 @@ import os import sys import threading import uuid -from typing import Mapping, NewType +from typing import Generator, Mapping, NamedTuple, NewType, TypedDict from urllib.parse import urlparse import modal @@ -14,10 +14,7 @@ SAMPLERATE = 16000 UPLOADS_PATH = "/uploads" CACHE_PATH = "/cache" VAD_CONFIG = { - "max_segment_duration": 30.0, - "batch_max_files": 10, - "batch_max_duration": 5.0, - "min_segment_duration": 0.02, + "batch_max_duration": 30.0, "silence_padding": 0.5, "window_size": 512, } @@ -25,6 +22,37 @@ VAD_CONFIG = { ParakeetUniqFilename = NewType("ParakeetUniqFilename", str) AudioFileExtension = NewType("AudioFileExtension", str) + +class TimeSegment(NamedTuple): + """Represents a time segment with start and end times.""" + + start: float + end: float + + +class AudioSegment(NamedTuple): + """Represents an audio segment with timing and audio data.""" + + start: float + end: float + audio: any + + +class TranscriptResult(NamedTuple): + """Represents a transcription result with text and word timings.""" + + text: str + words: list["WordTiming"] + + +class WordTiming(TypedDict): + """Represents a word with its timing information.""" + + word: str + start: float + end: float + + app = modal.App("reflector-transcriber-parakeet") # Volume for caching model weights @@ -49,13 +77,13 @@ image = ( .pip_install( "hf_transfer==0.1.9", "huggingface_hub[hf-xet]==0.31.2", - "nemo_toolkit[asr]==2.3.0", + "nemo_toolkit[asr]==2.5.0", "cuda-python==12.8.0", "fastapi==0.115.12", "numpy<2", - "librosa==0.10.1", + "librosa==0.11.0", "requests", - "silero-vad==5.1.0", + "silero-vad==6.2.0", "torch", ) .entrypoint([]) # silence chatty logs by container on start @@ -170,12 +198,14 @@ class TranscriberParakeetLive: (output,) = self.model.transcribe([padded_audio], timestamps=True) text = output.text.strip() - words = [ - { - "word": word_info["word"] + " ", - "start": round(word_info["start"], 2), - "end": round(word_info["end"], 2), - } + words: list[WordTiming] = [ + WordTiming( + # XXX the space added here is to match the output of whisper + # whisper add space to each words, while parakeet don't + word=word_info["word"] + " ", + start=round(word_info["start"], 2), + end=round(word_info["end"], 2), + ) for word_info in output.timestamp["word"] ] @@ -211,12 +241,12 @@ class TranscriberParakeetLive: for i, (filename, output) in enumerate(zip(filenames, outputs)): text = output.text.strip() - words = [ - { - "word": word_info["word"] + " ", - "start": round(word_info["start"], 2), - "end": round(word_info["end"], 2), - } + words: list[WordTiming] = [ + WordTiming( + word=word_info["word"] + " ", + start=round(word_info["start"], 2), + end=round(word_info["end"], 2), + ) for word_info in output.timestamp["word"] ] @@ -271,9 +301,12 @@ class TranscriberParakeetFile: audio_array, sample_rate = librosa.load(file_path, sr=SAMPLERATE, mono=True) return audio_array - def vad_segment_generator(audio_array): + def vad_segment_generator( + audio_array, + ) -> Generator[TimeSegment, None, None]: """Generate speech segments using VAD with start/end sample indices""" vad_iterator = VADIterator(self.vad_model, sampling_rate=SAMPLERATE) + audio_duration = len(audio_array) / float(SAMPLERATE) window_size = VAD_CONFIG["window_size"] start = None @@ -297,107 +330,125 @@ class TranscriberParakeetFile: start_time = start / float(SAMPLERATE) end_time = end / float(SAMPLERATE) - # Extract the actual audio segment - audio_segment = audio_array[start:end] - - yield (start_time, end_time, audio_segment) + yield TimeSegment(start_time, end_time) start = None + if start is not None: + start_time = start / float(SAMPLERATE) + yield TimeSegment(start_time, audio_duration) + vad_iterator.reset_states() - def vad_segment_filter(segments): - """Filter VAD segments by duration and chunk large segments""" - min_dur = VAD_CONFIG["min_segment_duration"] - max_dur = VAD_CONFIG["max_segment_duration"] + def batch_speech_segments( + segments: Generator[TimeSegment, None, None], max_duration: int + ) -> Generator[TimeSegment, None, None]: + """ + Input segments: + [0-2] [3-5] [6-8] [10-11] [12-15] [17-19] [20-22] - for start_time, end_time, audio_segment in segments: - segment_duration = end_time - start_time + ↓ (max_duration=10) - # Skip very small segments - if segment_duration < min_dur: + Output batches: + [0-8] [10-19] [20-22] + + Note: silences are kept for better transcription, previous implementation was + passing segments separatly, but the output was less accurate. + """ + batch_start_time = None + batch_end_time = None + + for segment in segments: + start_time, end_time = segment.start, segment.end + if batch_start_time is None or batch_end_time is None: + batch_start_time = start_time + batch_end_time = end_time continue - # If segment is within max duration, yield as-is - if segment_duration <= max_dur: - yield (start_time, end_time, audio_segment) + total_duration = end_time - batch_start_time + + if total_duration <= max_duration: + batch_end_time = end_time continue - # Chunk large segments into smaller pieces - chunk_samples = int(max_dur * SAMPLERATE) - current_start = start_time + yield TimeSegment(batch_start_time, batch_end_time) + batch_start_time = start_time + batch_end_time = end_time - for chunk_offset in range(0, len(audio_segment), chunk_samples): - chunk_audio = audio_segment[ - chunk_offset : chunk_offset + chunk_samples - ] - if len(chunk_audio) == 0: - break + if batch_start_time is None or batch_end_time is None: + return - chunk_duration = len(chunk_audio) / float(SAMPLERATE) - chunk_end = current_start + chunk_duration + yield TimeSegment(batch_start_time, batch_end_time) - # Only yield chunks that meet minimum duration - if chunk_duration >= min_dur: - yield (current_start, chunk_end, chunk_audio) + def batch_segment_to_audio_segment( + segments: Generator[TimeSegment, None, None], + audio_array, + ) -> Generator[AudioSegment, None, None]: + """Extract audio segments and apply padding for Parakeet compatibility. - current_start = chunk_end + Uses pad_audio to ensure segments are at least 0.5s long, preventing + Parakeet crashes. This padding may cause slight timing overlaps between + segments, which are corrected by enforce_word_timing_constraints. + """ + for segment in segments: + start_time, end_time = segment.start, segment.end + start_sample = int(start_time * SAMPLERATE) + end_sample = int(end_time * SAMPLERATE) + audio_segment = audio_array[start_sample:end_sample] - def batch_segments(segments, max_files=10, max_duration=5.0): - batch = [] - batch_duration = 0.0 + padded_segment = pad_audio(audio_segment, SAMPLERATE) - for start_time, end_time, audio_segment in segments: - segment_duration = end_time - start_time + yield AudioSegment(start_time, end_time, padded_segment) - if segment_duration < VAD_CONFIG["silence_padding"]: - silence_samples = int( - (VAD_CONFIG["silence_padding"] - segment_duration) * SAMPLERATE - ) - padding = np.zeros(silence_samples, dtype=np.float32) - audio_segment = np.concatenate([audio_segment, padding]) - segment_duration = VAD_CONFIG["silence_padding"] - - batch.append((start_time, end_time, audio_segment)) - batch_duration += segment_duration - - if len(batch) >= max_files or batch_duration >= max_duration: - yield batch - batch = [] - batch_duration = 0.0 - - if batch: - yield batch - - def transcribe_batch(model, audio_segments): + def transcribe_batch(model, audio_segments: list) -> list: with NoStdStreams(): outputs = model.transcribe(audio_segments, timestamps=True) return outputs + def enforce_word_timing_constraints( + words: list[WordTiming], + ) -> list[WordTiming]: + """Enforce that word end times don't exceed the start time of the next word. + + Due to silence padding added in batch_segment_to_audio_segment for better + transcription accuracy, word timings from different segments may overlap. + This function ensures there are no overlaps by adjusting end times. + """ + if len(words) <= 1: + return words + + enforced_words = [] + for i, word in enumerate(words): + enforced_word = word.copy() + + if i < len(words) - 1: + next_start = words[i + 1]["start"] + if enforced_word["end"] > next_start: + enforced_word["end"] = next_start + + enforced_words.append(enforced_word) + + return enforced_words + def emit_results( - results, - segments_info, - batch_index, - total_batches, - ): + results: list, + segments_info: list[AudioSegment], + ) -> Generator[TranscriptResult, None, None]: """Yield transcribed text and word timings from model output, adjusting timestamps to absolute positions.""" - for i, (output, (start_time, end_time, _)) in enumerate( - zip(results, segments_info) - ): + for i, (output, segment) in enumerate(zip(results, segments_info)): + start_time, end_time = segment.start, segment.end text = output.text.strip() - words = [ - { - "word": word_info["word"] + " ", - "start": round( + words: list[WordTiming] = [ + WordTiming( + word=word_info["word"] + " ", + start=round( word_info["start"] + start_time + timestamp_offset, 2 ), - "end": round( - word_info["end"] + start_time + timestamp_offset, 2 - ), - } + end=round(word_info["end"] + start_time + timestamp_offset, 2), + ) for word_info in output.timestamp["word"] ] - yield text, words + yield TranscriptResult(text, words) upload_volume.reload() @@ -407,41 +458,31 @@ class TranscriberParakeetFile: audio_array = load_and_convert_audio(file_path) total_duration = len(audio_array) / float(SAMPLERATE) - processed_duration = 0.0 - all_text_parts = [] - all_words = [] + all_text_parts: list[str] = [] + all_words: list[WordTiming] = [] raw_segments = vad_segment_generator(audio_array) - filtered_segments = vad_segment_filter(raw_segments) - batches = batch_segments( - filtered_segments, - VAD_CONFIG["batch_max_files"], + speech_segments = batch_speech_segments( + raw_segments, VAD_CONFIG["batch_max_duration"], ) + audio_segments = batch_segment_to_audio_segment(speech_segments, audio_array) - batch_index = 0 - total_batches = max( - 1, int(total_duration / VAD_CONFIG["batch_max_duration"]) + 1 - ) + for batch in audio_segments: + audio_segment = batch.audio + results = transcribe_batch(self.model, [audio_segment]) - for batch in batches: - batch_index += 1 - audio_segments = [seg[2] for seg in batch] - results = transcribe_batch(self.model, audio_segments) - - for text, words in emit_results( + for result in emit_results( results, - batch, - batch_index, - total_batches, + [batch], ): - if not text: + if not result.text: continue - all_text_parts.append(text) - all_words.extend(words) + all_text_parts.append(result.text) + all_words.extend(result.words) - processed_duration += sum(len(seg[2]) / float(SAMPLERATE) for seg in batch) + all_words = enforce_word_timing_constraints(all_words) combined_text = " ".join(all_text_parts) return {"text": combined_text, "words": all_words} diff --git a/server/gpu/modal_deployments/reflector_translator.py b/gpu/modal_deployments/reflector_translator.py similarity index 100% rename from server/gpu/modal_deployments/reflector_translator.py rename to gpu/modal_deployments/reflector_translator.py diff --git a/gpu/self_hosted/.env.example b/gpu/self_hosted/.env.example new file mode 100644 index 00000000..a55584ba --- /dev/null +++ b/gpu/self_hosted/.env.example @@ -0,0 +1,2 @@ +REFLECTOR_GPU_APIKEY= +HF_TOKEN= diff --git a/gpu/self_hosted/.gitignore b/gpu/self_hosted/.gitignore new file mode 100644 index 00000000..2773c2e2 --- /dev/null +++ b/gpu/self_hosted/.gitignore @@ -0,0 +1,38 @@ +cache/ + +# OS / Editor +.DS_Store +.vscode/ +.idea/ + +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Env and secrets +.env +*.env +*.secret +HF_TOKEN +REFLECTOR_GPU_APIKEY + +# Virtual env / uv +.venv/ +venv/ +ENV/ +uv/ + +# Build / dist +build/ +dist/ +.eggs/ +*.egg-info/ + +# Coverage / test +.pytest_cache/ +.coverage* +htmlcov/ + +# Logs +*.log diff --git a/gpu/self_hosted/Dockerfile b/gpu/self_hosted/Dockerfile new file mode 100644 index 00000000..4865fcc0 --- /dev/null +++ b/gpu/self_hosted/Dockerfile @@ -0,0 +1,46 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_NO_CACHE=1 + +WORKDIR /tmp +RUN apt-get update \ + && apt-get install -y \ + ffmpeg \ + curl \ + ca-certificates \ + gnupg \ + wget \ + && apt-get clean +# Add NVIDIA CUDA repo for Debian 12 (bookworm) and install cuDNN 9 for CUDA 12 +ADD https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/cuda-keyring_1.1-1_all.deb /cuda-keyring.deb +RUN dpkg -i /cuda-keyring.deb \ + && rm /cuda-keyring.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + cuda-cudart-12-6 \ + libcublas-12-6 \ + libcudnn9-cuda-12 \ + libcudnn9-dev-cuda-12 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +ADD https://astral.sh/uv/install.sh /uv-installer.sh +RUN sh /uv-installer.sh && rm /uv-installer.sh +ENV PATH="/root/.local/bin/:$PATH" +ENV LD_LIBRARY_PATH="/usr/local/cuda/lib64:/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH" + +RUN mkdir -p /app +WORKDIR /app +COPY pyproject.toml uv.lock /app/ + + +COPY ./app /app/app +COPY ./main.py /app/ +COPY ./runserver.sh /app/ + +EXPOSE 8000 + +CMD ["sh", "/app/runserver.sh"] + + diff --git a/gpu/self_hosted/README.md b/gpu/self_hosted/README.md new file mode 100644 index 00000000..0180a8ae --- /dev/null +++ b/gpu/self_hosted/README.md @@ -0,0 +1,73 @@ +# Self-hosted Model API + +Run transcription, translation, and diarization services compatible with Reflector's GPU Model API. Works on CPU or GPU. + +Environment variables + +- REFLECTOR_GPU_APIKEY: Optional Bearer token. If unset, auth is disabled. +- HF_TOKEN: Optional. Required for diarization to download pyannote pipelines + +Requirements + +- FFmpeg must be installed and on PATH (used for URL-based and segmented transcription) +- Python 3.12+ +- NVIDIA GPU optional. If available, it will be used automatically + +Local run +Set env vars in self_hosted/.env file +uv sync + +uv run uvicorn main:app --host 0.0.0.0 --port 8000 + +Authentication + +- If REFLECTOR_GPU_APIKEY is set, include header: Authorization: Bearer + +Endpoints + +- POST /v1/audio/transcriptions + + - multipart/form-data + - fields: file (single file) OR files[] (multiple files), language, batch (true/false) + - response: single { text, words, filename } or { results: [ ... ] } + +- POST /v1/audio/transcriptions-from-url + + - application/json + - body: { audio_file_url, language, timestamp_offset } + - response: { text, words } + +- POST /translate + + - text: query parameter + - body (application/json): { source_language, target_language } + - response: { text: { : original, : translated } } + +- POST /diarize + - query parameters: audio_file_url, timestamp (optional) + - requires HF_TOKEN to be set (for pyannote) + - response: { diarization: [ { start, end, speaker } ] } + +OpenAPI docs + +- Visit /docs when the server is running + +Docker + +- Not yet provided in this directory. A Dockerfile will be added later. For now, use Local run above + +Conformance tests + +# From this directory + +TRANSCRIPT_URL=http://localhost:8000 \ +TRANSCRIPT_API_KEY=dev-key \ +uv run -m pytest -m model_api --no-cov ../../server/tests/test_model_api_transcript.py + +TRANSLATION_URL=http://localhost:8000 \ +TRANSLATION_API_KEY=dev-key \ +uv run -m pytest -m model_api --no-cov ../../server/tests/test_model_api_translation.py + +DIARIZATION_URL=http://localhost:8000 \ +DIARIZATION_API_KEY=dev-key \ +uv run -m pytest -m model_api --no-cov ../../server/tests/test_model_api_diarization.py diff --git a/gpu/self_hosted/app/auth.py b/gpu/self_hosted/app/auth.py new file mode 100644 index 00000000..9c74e90c --- /dev/null +++ b/gpu/self_hosted/app/auth.py @@ -0,0 +1,19 @@ +import os + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") + + +def apikey_auth(apikey: str = Depends(oauth2_scheme)): + required_key = os.environ.get("REFLECTOR_GPU_APIKEY") + if not required_key: + return + if apikey == required_key: + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/gpu/self_hosted/app/config.py b/gpu/self_hosted/app/config.py new file mode 100644 index 00000000..5c466f00 --- /dev/null +++ b/gpu/self_hosted/app/config.py @@ -0,0 +1,12 @@ +from pathlib import Path + +SUPPORTED_FILE_EXTENSIONS = ["mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"] +SAMPLE_RATE = 16000 +VAD_CONFIG = { + "batch_max_duration": 30.0, + "silence_padding": 0.5, + "window_size": 512, +} + +# App-level paths +UPLOADS_PATH = Path("/tmp/whisper-uploads") diff --git a/gpu/self_hosted/app/factory.py b/gpu/self_hosted/app/factory.py new file mode 100644 index 00000000..72dadcd7 --- /dev/null +++ b/gpu/self_hosted/app/factory.py @@ -0,0 +1,30 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from .routers.diarization import router as diarization_router +from .routers.transcription import router as transcription_router +from .routers.translation import router as translation_router +from .services.transcriber import WhisperService +from .services.diarizer import PyannoteDiarizationService +from .utils import ensure_dirs + + +@asynccontextmanager +async def lifespan(app: FastAPI): + ensure_dirs() + whisper_service = WhisperService() + whisper_service.load() + app.state.whisper = whisper_service + diarization_service = PyannoteDiarizationService() + diarization_service.load() + app.state.diarizer = diarization_service + yield + + +def create_app() -> FastAPI: + app = FastAPI(lifespan=lifespan) + app.include_router(transcription_router) + app.include_router(translation_router) + app.include_router(diarization_router) + return app diff --git a/gpu/self_hosted/app/routers/diarization.py b/gpu/self_hosted/app/routers/diarization.py new file mode 100644 index 00000000..113a8957 --- /dev/null +++ b/gpu/self_hosted/app/routers/diarization.py @@ -0,0 +1,30 @@ +from typing import List + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from ..auth import apikey_auth +from ..services.diarizer import PyannoteDiarizationService +from ..utils import download_audio_file + +router = APIRouter(tags=["diarization"]) + + +class DiarizationSegment(BaseModel): + start: float + end: float + speaker: int + + +class DiarizationResponse(BaseModel): + diarization: List[DiarizationSegment] + + +@router.post( + "/diarize", dependencies=[Depends(apikey_auth)], response_model=DiarizationResponse +) +def diarize(request: Request, audio_file_url: str, timestamp: float = 0.0): + with download_audio_file(audio_file_url) as (file_path, _ext): + file_path = str(file_path) + diarizer: PyannoteDiarizationService = request.app.state.diarizer + return diarizer.diarize_file(file_path, timestamp=timestamp) diff --git a/gpu/self_hosted/app/routers/transcription.py b/gpu/self_hosted/app/routers/transcription.py new file mode 100644 index 00000000..04f1f7f7 --- /dev/null +++ b/gpu/self_hosted/app/routers/transcription.py @@ -0,0 +1,109 @@ +import uuid +from typing import Optional, Union + +from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request, UploadFile +from pydantic import BaseModel +from pathlib import Path +from ..auth import apikey_auth +from ..config import SUPPORTED_FILE_EXTENSIONS, UPLOADS_PATH +from ..services.transcriber import MODEL_NAME +from ..utils import cleanup_uploaded_files, download_audio_file + +router = APIRouter(prefix="/v1/audio", tags=["transcription"]) + + +class WordTiming(BaseModel): + word: str + start: float + end: float + + +class TranscriptResult(BaseModel): + text: str + words: list[WordTiming] + filename: Optional[str] = None + + +class TranscriptBatchResponse(BaseModel): + results: list[TranscriptResult] + + +@router.post( + "/transcriptions", + dependencies=[Depends(apikey_auth)], + response_model=Union[TranscriptResult, TranscriptBatchResponse], +) +def transcribe( + request: Request, + file: UploadFile = None, + files: list[UploadFile] | None = None, + model: str = Form(MODEL_NAME), + language: str = Form("en"), + batch: bool = Form(False), +): + service = request.app.state.whisper + if not file and not files: + raise HTTPException( + status_code=400, detail="Either 'file' or 'files' parameter is required" + ) + if batch and not files: + raise HTTPException( + status_code=400, detail="Batch transcription requires 'files'" + ) + + upload_files = [file] if file else files + + uploaded_paths: list[Path] = [] + with cleanup_uploaded_files(uploaded_paths): + for upload_file in upload_files: + audio_suffix = upload_file.filename.split(".")[-1].lower() + if audio_suffix not in SUPPORTED_FILE_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported audio format. Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}" + ), + ) + unique_filename = f"{uuid.uuid4()}.{audio_suffix}" + file_path = UPLOADS_PATH / unique_filename + with open(file_path, "wb") as f: + content = upload_file.file.read() + f.write(content) + uploaded_paths.append(file_path) + + if batch and len(upload_files) > 1: + results = [] + for path in uploaded_paths: + result = service.transcribe_file(str(path), language=language) + result["filename"] = path.name + results.append(result) + return {"results": results} + + results = [] + for path in uploaded_paths: + result = service.transcribe_file(str(path), language=language) + result["filename"] = path.name + results.append(result) + + return {"results": results} if len(results) > 1 else results[0] + + +@router.post( + "/transcriptions-from-url", + dependencies=[Depends(apikey_auth)], + response_model=TranscriptResult, +) +def transcribe_from_url( + request: Request, + audio_file_url: str = Body(..., description="URL of the audio file to transcribe"), + model: str = Body(MODEL_NAME), + language: str = Body("en"), + timestamp_offset: float = Body(0.0), +): + service = request.app.state.whisper + with download_audio_file(audio_file_url) as (file_path, _ext): + file_path = str(file_path) + result = service.transcribe_vad_url_segment( + file_path=file_path, timestamp_offset=timestamp_offset, language=language + ) + return result diff --git a/gpu/self_hosted/app/routers/translation.py b/gpu/self_hosted/app/routers/translation.py new file mode 100644 index 00000000..d2025416 --- /dev/null +++ b/gpu/self_hosted/app/routers/translation.py @@ -0,0 +1,28 @@ +from typing import Dict + +from fastapi import APIRouter, Body, Depends +from pydantic import BaseModel + +from ..auth import apikey_auth +from ..services.translator import TextTranslatorService + +router = APIRouter(tags=["translation"]) + +translator = TextTranslatorService() + + +class TranslationResponse(BaseModel): + text: Dict[str, str] + + +@router.post( + "/translate", + dependencies=[Depends(apikey_auth)], + response_model=TranslationResponse, +) +def translate( + text: str, + source_language: str = Body("en"), + target_language: str = Body("fr"), +): + return translator.translate(text, source_language, target_language) diff --git a/gpu/self_hosted/app/services/diarizer.py b/gpu/self_hosted/app/services/diarizer.py new file mode 100644 index 00000000..2935ffc5 --- /dev/null +++ b/gpu/self_hosted/app/services/diarizer.py @@ -0,0 +1,42 @@ +import os +import threading + +import torch +import torchaudio +from pyannote.audio import Pipeline + + +class PyannoteDiarizationService: + def __init__(self): + self._pipeline = None + self._device = "cpu" + self._lock = threading.Lock() + + def load(self): + self._device = "cuda" if torch.cuda.is_available() else "cpu" + self._pipeline = Pipeline.from_pretrained( + "pyannote/speaker-diarization-3.1", + use_auth_token=os.environ.get("HF_TOKEN"), + ) + self._pipeline.to(torch.device(self._device)) + + def diarize_file(self, file_path: str, timestamp: float = 0.0) -> dict: + if self._pipeline is None: + self.load() + waveform, sample_rate = torchaudio.load(file_path) + with self._lock: + diarization = self._pipeline( + {"waveform": waveform, "sample_rate": sample_rate} + ) + words = [] + for diarization_segment, _, speaker in diarization.itertracks(yield_label=True): + words.append( + { + "start": round(timestamp + diarization_segment.start, 3), + "end": round(timestamp + diarization_segment.end, 3), + "speaker": int(speaker[-2:]) + if speaker and speaker[-2:].isdigit() + else 0, + } + ) + return {"diarization": words} diff --git a/gpu/self_hosted/app/services/transcriber.py b/gpu/self_hosted/app/services/transcriber.py new file mode 100644 index 00000000..26a313cc --- /dev/null +++ b/gpu/self_hosted/app/services/transcriber.py @@ -0,0 +1,208 @@ +import os +import shutil +import subprocess +import threading +from typing import Generator + +import faster_whisper +import librosa +import numpy as np +import torch +from fastapi import HTTPException +from silero_vad import VADIterator, load_silero_vad + +from ..config import SAMPLE_RATE, VAD_CONFIG + +# Whisper configuration (service-local defaults) +MODEL_NAME = "large-v2" +# None delegates compute type to runtime: float16 on CUDA, int8 on CPU +MODEL_COMPUTE_TYPE = None +MODEL_NUM_WORKERS = 1 +CACHE_PATH = os.path.join(os.path.expanduser("~"), ".cache", "reflector-whisper") +from ..utils import NoStdStreams + + +class WhisperService: + def __init__(self): + self.model = None + self.device = "cpu" + self.lock = threading.Lock() + + def load(self): + self.device = "cuda" if torch.cuda.is_available() else "cpu" + compute_type = MODEL_COMPUTE_TYPE or ( + "float16" if self.device == "cuda" else "int8" + ) + self.model = faster_whisper.WhisperModel( + MODEL_NAME, + device=self.device, + compute_type=compute_type, + num_workers=MODEL_NUM_WORKERS, + download_root=CACHE_PATH, + ) + + def pad_audio(self, audio_array, sample_rate: int = SAMPLE_RATE): + audio_duration = len(audio_array) / sample_rate + if audio_duration < VAD_CONFIG["silence_padding"]: + silence_samples = int(sample_rate * VAD_CONFIG["silence_padding"]) + silence = np.zeros(silence_samples, dtype=np.float32) + return np.concatenate([audio_array, silence]) + return audio_array + + def enforce_word_timing_constraints(self, words: list[dict]) -> list[dict]: + if len(words) <= 1: + return words + enforced: list[dict] = [] + for i, word in enumerate(words): + current = dict(word) + if i < len(words) - 1: + next_start = words[i + 1]["start"] + if current["end"] > next_start: + current["end"] = next_start + enforced.append(current) + return enforced + + def transcribe_file(self, file_path: str, language: str = "en") -> dict: + input_for_model: str | "object" = file_path + try: + audio_array, _sample_rate = librosa.load( + file_path, sr=SAMPLE_RATE, mono=True + ) + if len(audio_array) / float(SAMPLE_RATE) < VAD_CONFIG["silence_padding"]: + input_for_model = self.pad_audio(audio_array, SAMPLE_RATE) + except Exception: + pass + + with self.lock: + with NoStdStreams(): + segments, _ = self.model.transcribe( + input_for_model, + language=language, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": 500}, + ) + + segments = list(segments) + text = "".join(segment.text for segment in segments).strip() + words = [ + { + "word": word.word, + "start": round(float(word.start), 2), + "end": round(float(word.end), 2), + } + for segment in segments + for word in segment.words + ] + words = self.enforce_word_timing_constraints(words) + return {"text": text, "words": words} + + def transcribe_vad_url_segment( + self, file_path: str, timestamp_offset: float = 0.0, language: str = "en" + ) -> dict: + def load_audio_via_ffmpeg(input_path: str, sample_rate: int) -> np.ndarray: + ffmpeg_bin = shutil.which("ffmpeg") or "ffmpeg" + cmd = [ + ffmpeg_bin, + "-nostdin", + "-threads", + "1", + "-i", + input_path, + "-f", + "f32le", + "-acodec", + "pcm_f32le", + "-ac", + "1", + "-ar", + str(sample_rate), + "pipe:1", + ] + try: + proc = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True + ) + except Exception as e: + raise HTTPException(status_code=400, detail=f"ffmpeg failed: {e}") + audio = np.frombuffer(proc.stdout, dtype=np.float32) + return audio + + def vad_segments( + audio_array, + sample_rate: int = SAMPLE_RATE, + window_size: int = VAD_CONFIG["window_size"], + ) -> Generator[tuple[float, float], None, None]: + vad_model = load_silero_vad(onnx=False) + iterator = VADIterator(vad_model, sampling_rate=sample_rate) + start = None + for i in range(0, len(audio_array), window_size): + chunk = audio_array[i : i + window_size] + if len(chunk) < window_size: + chunk = np.pad( + chunk, (0, window_size - len(chunk)), mode="constant" + ) + speech = iterator(chunk) + if not speech: + continue + if "start" in speech: + start = speech["start"] + continue + if "end" in speech and start is not None: + end = speech["end"] + yield (start / float(SAMPLE_RATE), end / float(SAMPLE_RATE)) + start = None + iterator.reset_states() + + audio_array = load_audio_via_ffmpeg(file_path, SAMPLE_RATE) + + merged_batches: list[tuple[float, float]] = [] + batch_start = None + batch_end = None + max_duration = VAD_CONFIG["batch_max_duration"] + for seg_start, seg_end in vad_segments(audio_array): + if batch_start is None: + batch_start, batch_end = seg_start, seg_end + continue + if seg_end - batch_start <= max_duration: + batch_end = seg_end + else: + merged_batches.append((batch_start, batch_end)) + batch_start, batch_end = seg_start, seg_end + if batch_start is not None and batch_end is not None: + merged_batches.append((batch_start, batch_end)) + + all_text = [] + all_words = [] + for start_time, end_time in merged_batches: + s_idx = int(start_time * SAMPLE_RATE) + e_idx = int(end_time * SAMPLE_RATE) + segment = audio_array[s_idx:e_idx] + segment = self.pad_audio(segment, SAMPLE_RATE) + with self.lock: + segments, _ = self.model.transcribe( + segment, + language=language, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": 500}, + ) + segments = list(segments) + text = "".join(seg.text for seg in segments).strip() + words = [ + { + "word": w.word, + "start": round(float(w.start) + start_time + timestamp_offset, 2), + "end": round(float(w.end) + start_time + timestamp_offset, 2), + } + for seg in segments + for w in seg.words + ] + if text: + all_text.append(text) + all_words.extend(words) + + all_words = self.enforce_word_timing_constraints(all_words) + return {"text": " ".join(all_text), "words": all_words} diff --git a/gpu/self_hosted/app/services/translator.py b/gpu/self_hosted/app/services/translator.py new file mode 100644 index 00000000..bda7373f --- /dev/null +++ b/gpu/self_hosted/app/services/translator.py @@ -0,0 +1,44 @@ +import threading + +from transformers import MarianMTModel, MarianTokenizer, pipeline + + +class TextTranslatorService: + """Simple text-to-text translator using HuggingFace MarianMT models. + + This mirrors the modal translator API shape but uses text translation only. + """ + + def __init__(self): + self._pipeline = None + self._lock = threading.Lock() + + def load(self, source_language: str = "en", target_language: str = "fr"): + # Pick a default MarianMT model pair if available; fall back to Helsinki-NLP en->fr + model_name = self._resolve_model_name(source_language, target_language) + tokenizer = MarianTokenizer.from_pretrained(model_name) + model = MarianMTModel.from_pretrained(model_name) + self._pipeline = pipeline("translation", model=model, tokenizer=tokenizer) + + def _resolve_model_name(self, src: str, tgt: str) -> str: + # Minimal mapping; extend as needed + pair = (src.lower(), tgt.lower()) + mapping = { + ("en", "fr"): "Helsinki-NLP/opus-mt-en-fr", + ("fr", "en"): "Helsinki-NLP/opus-mt-fr-en", + ("en", "es"): "Helsinki-NLP/opus-mt-en-es", + ("es", "en"): "Helsinki-NLP/opus-mt-es-en", + ("en", "de"): "Helsinki-NLP/opus-mt-en-de", + ("de", "en"): "Helsinki-NLP/opus-mt-de-en", + } + return mapping.get(pair, "Helsinki-NLP/opus-mt-en-fr") + + def translate(self, text: str, source_language: str, target_language: str) -> dict: + if self._pipeline is None: + self.load(source_language, target_language) + with self._lock: + results = self._pipeline( + text, src_lang=source_language, tgt_lang=target_language + ) + translated = results[0]["translation_text"] if results else "" + return {"text": {source_language: text, target_language: translated}} diff --git a/gpu/self_hosted/app/utils.py b/gpu/self_hosted/app/utils.py new file mode 100644 index 00000000..679804cb --- /dev/null +++ b/gpu/self_hosted/app/utils.py @@ -0,0 +1,107 @@ +import logging +import os +import sys +import uuid +from contextlib import contextmanager +from typing import Mapping +from urllib.parse import urlparse +from pathlib import Path + +import requests +from fastapi import HTTPException + +from .config import SUPPORTED_FILE_EXTENSIONS, UPLOADS_PATH + +logger = logging.getLogger(__name__) + + +class NoStdStreams: + def __init__(self): + self.devnull = open(os.devnull, "w") + + def __enter__(self): + self._stdout, self._stderr = sys.stdout, sys.stderr + self._stdout.flush() + self._stderr.flush() + sys.stdout, sys.stderr = self.devnull, self.devnull + + def __exit__(self, exc_type, exc_value, traceback): + sys.stdout, sys.stderr = self._stdout, self._stderr + self.devnull.close() + + +def ensure_dirs(): + UPLOADS_PATH.mkdir(parents=True, exist_ok=True) + + +def detect_audio_format(url: str, headers: Mapping[str, str]) -> str: + url_path = urlparse(url).path + for ext in SUPPORTED_FILE_EXTENSIONS: + if url_path.lower().endswith(f".{ext}"): + return ext + + content_type = headers.get("content-type", "").lower() + if "audio/mpeg" in content_type or "audio/mp3" in content_type: + return "mp3" + if "audio/wav" in content_type: + return "wav" + if "audio/mp4" in content_type: + return "mp4" + + raise HTTPException( + status_code=400, + detail=( + f"Unsupported audio format for URL. Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}" + ), + ) + + +def download_audio_to_uploads(audio_file_url: str) -> tuple[Path, str]: + response = requests.head(audio_file_url, allow_redirects=True) + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Audio file not found") + + response = requests.get(audio_file_url, allow_redirects=True) + response.raise_for_status() + + audio_suffix = detect_audio_format(audio_file_url, response.headers) + unique_filename = f"{uuid.uuid4()}.{audio_suffix}" + file_path: Path = UPLOADS_PATH / unique_filename + + with open(file_path, "wb") as f: + f.write(response.content) + + return file_path, audio_suffix + + +@contextmanager +def download_audio_file(audio_file_url: str): + """Download an audio file to UPLOADS_PATH and remove it after use. + + Yields (file_path: Path, audio_suffix: str). + """ + file_path, audio_suffix = download_audio_to_uploads(audio_file_url) + try: + yield file_path, audio_suffix + finally: + try: + file_path.unlink(missing_ok=True) + except Exception as e: + logger.error("Error deleting temporary file %s: %s", file_path, e) + + +@contextmanager +def cleanup_uploaded_files(file_paths: list[Path]): + """Ensure provided file paths are removed after use. + + The provided list can be populated inside the context; all present entries + at exit will be deleted. + """ + try: + yield file_paths + finally: + for path in list(file_paths): + try: + path.unlink(missing_ok=True) + except Exception as e: + logger.error("Error deleting temporary file %s: %s", path, e) diff --git a/gpu/self_hosted/compose.yml b/gpu/self_hosted/compose.yml new file mode 100644 index 00000000..4f04935a --- /dev/null +++ b/gpu/self_hosted/compose.yml @@ -0,0 +1,10 @@ +services: + reflector_gpu: + build: + context: . + ports: + - "8000:8000" + env_file: + - .env + volumes: + - ./cache:/root/.cache diff --git a/gpu/self_hosted/main.py b/gpu/self_hosted/main.py new file mode 100644 index 00000000..52617d24 --- /dev/null +++ b/gpu/self_hosted/main.py @@ -0,0 +1,3 @@ +from app.factory import create_app + +app = create_app() diff --git a/gpu/self_hosted/pyproject.toml b/gpu/self_hosted/pyproject.toml new file mode 100644 index 00000000..7cd3007d --- /dev/null +++ b/gpu/self_hosted/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "reflector-gpu" +version = "0.1.0" +description = "Self-hosted GPU service for speech transcription, diarization, and translation via FastAPI." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi[standard]>=0.116.1", + "uvicorn[standard]>=0.30.0", + "torch>=2.3.0", + "faster-whisper>=1.1.0", + "librosa==0.10.1", + "numpy<2", + "silero-vad==5.1.0", + "transformers>=4.35.0", + "sentencepiece", + "pyannote.audio==3.1.0", + "torchaudio>=2.3.0", +] diff --git a/gpu/self_hosted/runserver.sh b/gpu/self_hosted/runserver.sh new file mode 100644 index 00000000..851dd535 --- /dev/null +++ b/gpu/self_hosted/runserver.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -e + +export PATH="/root/.local/bin:$PATH" +cd /app + +# Install Python dependencies at runtime (first run or when FORCE_SYNC=1) +if [ ! -d "/app/.venv" ] || [ "$FORCE_SYNC" = "1" ]; then + echo "[startup] Installing Python dependencies with uv..." + uv sync --compile-bytecode --locked +else + echo "[startup] Using existing virtual environment at /app/.venv" +fi + +exec uv run uvicorn main:app --host 0.0.0.0 --port 8000 + + diff --git a/gpu/self_hosted/uv.lock b/gpu/self_hosted/uv.lock new file mode 100644 index 00000000..224e9d33 --- /dev/null +++ b/gpu/self_hosted/uv.lock @@ -0,0 +1,3013 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.12.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" }, + { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" }, + { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" }, + { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" }, + { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" }, + { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" }, + { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" }, + { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" }, + { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" }, + { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" }, + { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" }, + { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" }, + { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "asteroid-filterbanks" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "torch" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/fa/5c2be1f96dc179f83cdd3bb267edbd1f47d08f756785c016d5c2163901a7/asteroid-filterbanks-0.4.0.tar.gz", hash = "sha256:415f89d1dcf2b13b35f03f7a9370968ac4e6fa6800633c522dac992b283409b9", size = 24599, upload-time = "2021-04-09T20:03:07.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7c/83ff6046176a675e6a1e8aeefed8892cd97fe7c46af93cc540d1b24b8323/asteroid_filterbanks-0.4.0-py3-none-any.whl", hash = "sha256:4932ac8b6acc6e08fb87cbe8ece84215b5a74eee284fe83acf3540a72a02eaf5", size = 29912, upload-time = "2021-04-09T20:03:05.817Z" }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[package]] +name = "audioread" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/d2/87016ca9f083acadffb2d8da59bfa3253e4da7eeb9f71fb8e7708dc97ecd/audioread-3.0.1.tar.gz", hash = "sha256:ac5460a5498c48bdf2e8e767402583a4dcd13f4414d286f42ce4379e8b35066d", size = 116513, upload-time = "2023-09-27T19:27:53.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/8d/30aa32745af16af0a9a650115fbe81bde7c610ed5c21b381fca0196f3a7f/audioread-3.0.1-py3-none-any.whl", hash = "sha256:4cdce70b8adc0da0a3c9e0d85fb10b3ace30fbdf8d1670fd443929b61d117c33", size = 23492, upload-time = "2023-09-27T19:27:51.334Z" }, +] + +[[package]] +name = "av" +version = "15.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/c3/83e6e73d1592bc54436eae0bc61704ae0cff0c3cfbde7b58af9ed67ebb49/av-15.1.0.tar.gz", hash = "sha256:39cda2dc810e11c1938f8cb5759c41d6b630550236b3365790e67a313660ec85", size = 3774192, upload-time = "2025-08-30T04:41:56.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/58/de78b276d20db6ffcd4371283df771721a833ba525a3d57e753d00a9fe79/av-15.1.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:40c5df37f4c354ab8190c6fd68dab7881d112f527906f64ca73da4c252a58cee", size = 21760991, upload-time = "2025-08-30T04:40:00.801Z" }, + { url = "https://files.pythonhosted.org/packages/56/cc/45f85775304ae60b66976360d82ba5b152ad3fd91f9267d5020a51e9a828/av-15.1.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:af455ce65ada3d361f80c90c810d9bced4db5655ab9aa513024d6c71c5c476d5", size = 26953097, upload-time = "2025-08-30T04:40:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/2d781e5e71d02fc829487e775ccb1185e72f95340d05f2e84eb57a11e093/av-15.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86226d2474c80c3393fa07a9c366106029ae500716098b72b3ec3f67205524c3", size = 38319710, upload-time = "2025-08-30T04:40:07.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/13/37737ef2193e83862ccacff23580c39de251da456a1bf0459e762cca273c/av-15.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:11326f197e7001c4ca53a83b2dbc67fd39ddff8cdf62ce6be3b22d9f3f9338bd", size = 39915519, upload-time = "2025-08-30T04:40:11.066Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e8032c7b8f2a4129a03f63f896544f8b7cf068e2db2950326fa2400d5c47/av-15.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a631ea879cc553080ee62874f4284765c42ba08ee0279851a98a85e2ceb3cc8d", size = 40286166, upload-time = "2025-08-30T04:40:14.561Z" }, + { url = "https://files.pythonhosted.org/packages/e2/23/612c0fd809444d04b8387a2dfd942ccc77829507bd78a387ff65a9d98c24/av-15.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f383949b010c3e731c245f80351d19dc0c08f345e194fc46becb1cb279be3ff", size = 41150592, upload-time = "2025-08-30T04:40:17.951Z" }, + { url = "https://files.pythonhosted.org/packages/15/74/6f8e38a3b0aea5f28e72813672ff45b64615f2c69e6a4a558718c95edb9f/av-15.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d5921aa45f4c1f8c1a8d8185eb347e02aa4c3071278a2e2dd56368d54433d643", size = 31336093, upload-time = "2025-08-30T04:40:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/2e/bc/78b2ffa8235eeffc29aa4a8cc47b02e660cfec32f601f39a00975fb06d0e/av-15.1.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2f77853c3119c59d1bff4214ccbe46e3133eccff85ed96adee51c68684443f4e", size = 21726244, upload-time = "2025-08-30T04:40:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/99/66d69453a2dce028e6e8ebea085d90e880aac03d3a3ab7d8ec16755ffd75/av-15.1.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:c0bc4471c156a0a1c70a607502434f477bc8dfe085eef905e55b4b0d66bcd3a5", size = 26918663, upload-time = "2025-08-30T04:40:27.557Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/1a7dfbeda71f2772bc46d758af0e7fab1cc8388ce4bc7f24aecbc4bfd764/av-15.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:37839d4fa1407f047af82560dfc0f94d8d6266071eff49e1cbe16c4483054621", size = 38041408, upload-time = "2025-08-30T04:40:30.811Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/2c4e0288ad4359b6064cb06ae79c2ff3a84ac73d27e91f2161b75fcd86fa/av-15.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:729179cd8622815e8b6f6854d13a806fe710576e08895c77e5e4ad254609de9a", size = 39642563, upload-time = "2025-08-30T04:40:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/2362502149e276d00957edabcc201a5f4d5109a8a7b4fd30793714a532f3/av-15.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4abdf085bfa4eec318efccff567831b361ea56c045cc38366811552e3127c665", size = 40022119, upload-time = "2025-08-30T04:40:37.703Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/1a0ce1b3835d9728da0a7a54aeffaa0a2b1a88405eaed9322efd55212a54/av-15.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f985661644879e4520d28a995fcb2afeb951bc15a1d51412eb8e5f36da85b6fe", size = 40885158, upload-time = "2025-08-30T04:40:40.952Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/054bb64e424d90b77ed5fc6a7358e4013fb436154c998fc90a89a186313f/av-15.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:7d7804a44c8048bb4b014a99353dd124663a12cd1d4613ba2bd3b457c3b1d539", size = 31312256, upload-time = "2025-08-30T04:40:44.224Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/89eae6dca10d7d2b83c131025a31ccc750be78699ac0304439faa1d1df99/av-15.1.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:5dd73c6447947edcb82e5fecf96e1f146aeda0f169c7ad4c54df4d9f66f63fde", size = 21730645, upload-time = "2025-08-30T04:40:47.259Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f0/abffaf69405ed68041524be12a1e294faf396971d6a0e70eb00e93687df7/av-15.1.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:a81cd515934a5d51290aa66b059b7ed29c4a212e704f3c5e99e32877ff1c312c", size = 26913753, upload-time = "2025-08-30T04:40:50.445Z" }, + { url = "https://files.pythonhosted.org/packages/37/9e/7af078bcfc3cd340c981ac5d613c090ab007023d2ac13b05acd52f22f069/av-15.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:57cc7a733a7e7d7a153682f35c9cf5d01e8269367b049c954779de36fc3d0b10", size = 38027048, upload-time = "2025-08-30T04:40:54.076Z" }, + { url = "https://files.pythonhosted.org/packages/02/76/1f9dac11ad713e3619288993ea04e9c9cf4ec0f04e5ee81e83b8129dd8f3/av-15.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a77b75bdb6899a64302ff923a5246e0747b3f0a3ecee7d61118db407a22c3f53", size = 39565396, upload-time = "2025-08-30T04:40:57.84Z" }, + { url = "https://files.pythonhosted.org/packages/8b/32/2188c46e2747247458ffc26b230c57dd28e61f65ff7b9e6223a411af5e98/av-15.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d0a1154ce081f1720082a133cfe12356c59f62dad2b93a7a1844bf1dcd010d85", size = 40015050, upload-time = "2025-08-30T04:41:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/1e/41/b57fbce9994580619d7574817ece0fe0e7b822cde2af57904549d0150b8d/av-15.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a7bf5a34dee15c86790414fa86a144e6d0dcc788bc83b565fdcbc080b4fbc90", size = 40821225, upload-time = "2025-08-30T04:41:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/b1/36/e85cd1f0d3369c6764ad422882895d082f7ececb66d3df8aeae3234ef7a6/av-15.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:e30c9a6fd9734784941384a2e25fad3c22881a7682f378914676aa7e795acdb7", size = 31311750, upload-time = "2025-08-30T04:41:07.744Z" }, + { url = "https://files.pythonhosted.org/packages/80/d8/08a681758a4e49adfda409a6a35eff533f42654c6a6cfa102bc5cae1a728/av-15.1.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:60666833d7e65ebcfc48034a072de74349edbb62c9aaa3e6722fef31ca028eb6", size = 21828343, upload-time = "2025-08-30T04:41:10.81Z" }, + { url = "https://files.pythonhosted.org/packages/4a/52/29bec3fe68669b21f7d1ab5d94e21f597b8dfd37f50a3e3c9af6a8da925c/av-15.1.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:53fbdae45aa2a49a22e864ff4f4017416ef62c060a172085d3247ba0a101104e", size = 27001666, upload-time = "2025-08-30T04:41:13.822Z" }, + { url = "https://files.pythonhosted.org/packages/9d/54/2c1d1faced66d708f5df328e800997cb47f90b500a214130c3a0f2ad601e/av-15.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e6c51061667983dc801502aff9140bbc4f0e0d97f879586f17fb2f9a7e49c381", size = 39496753, upload-time = "2025-08-30T04:41:16.759Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/06ded5e52c4dcc2d9b5184c6da8de5ea77bd7ecb79a59a2b9700f1984949/av-15.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:2f80ec387f04aa34868662b11018b5f09654ae1530a61e24e92a142a24b10b62", size = 40784729, upload-time = "2025-08-30T04:41:20.491Z" }, + { url = "https://files.pythonhosted.org/packages/52/ef/797b76f3b39c99a96e387f501bbc07dca340b27d3dda12862fe694066b63/av-15.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4975e03177d37d8165c99c8d494175675ba8acb72458fb5d7e43f746a53e0374", size = 41284953, upload-time = "2025-08-30T04:41:23.949Z" }, + { url = "https://files.pythonhosted.org/packages/31/47/e4656f00e62fd059ea5a40b492dea784f5aecfe1dfac10c0d7a0664ce200/av-15.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8f78f3dad11780b4cdd024cdb92ce43cb170929297c00f2f4555c2b103f51e55", size = 41985340, upload-time = "2025-08-30T04:41:27.561Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/15bb4fd7a1f39d70db35af2b9c20a0ae19e4220eb58a8b8446e903b98d72/av-15.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a20c5eba3ec49c2f4b281797021923fc68a86aeb66c5cda4fd0252fa8004951", size = 31487337, upload-time = "2025-08-30T04:41:30.591Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[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 = "coloredlogs" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, +] + +[[package]] +name = "colorlog" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "ctranslate2" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/e9/3f1e35528b445b2fc928063f3ddd1ca5ac195b08c28ab10312e599c5cf28/ctranslate2-4.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff3ad05010857d450ee40fd9c28a33c10215a7180e189151e378ed2d19be8a57", size = 13310925, upload-time = "2025-04-08T19:49:47.051Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/3880c3be097596a523cb24b52dc0514f685c2ec0bab9cceaeed874aeddec/ctranslate2-4.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78a844c633b6d450b20adac296f7f60ac2a67f2c76e510a83c8916835dc13f04", size = 1297913, upload-time = "2025-04-08T19:49:48.702Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b3/77af5ad0e896dd27a10db768d7a67b8807e394c8e68c2fa559c662a33547/ctranslate2-4.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44bf4b973ea985b80696093e11e9c72909aee55b35abb749428333822c70ce68", size = 17485132, upload-time = "2025-04-08T19:49:50.076Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e9/06c2bf49d6808359d71f1126ec5b8e5a5c3c9526899ed58f24666e0e1b86/ctranslate2-4.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b2ca5c2905b540dd833a0b75d912ec9acc18d33a2dc4f85f12032851659a0d", size = 38816537, upload-time = "2025-04-08T19:49:52.735Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/0ecd260233290bee4b2facec4d8e755e57d8781d68f276e1248433993c9f/ctranslate2-4.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:511cdf810a5bf6a2cec735799e5cd47966e63f8f7688fdee1b97fed621abda00", size = 19470040, upload-time = "2025-04-08T19:49:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/59/96/dea1633368d60eb3da7403f3773cc2ba7988e56044ae155f68ab1ebb8f81/ctranslate2-4.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6283ffe63831b980282ff64ab845c62c7ef771f2ce06cb34825fd7578818bf07", size = 13310770, upload-time = "2025-04-08T19:49:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/1b/65/d6470f6cfb10e5a065bd71c8cf99d5d107a9d33caedaa622ad7bd9dca01d/ctranslate2-4.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2ebaae12ade184a235569235a875cf03d53b07732342f93b96ae76ef02c31961", size = 1297777, upload-time = "2025-04-08T19:49:59.383Z" }, + { url = "https://files.pythonhosted.org/packages/13/52/249565849281e7d6c997ffca88447b8806c119e1b0d1f799c27dda061440/ctranslate2-4.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a719cd765ec10fe20f9a866093e777a000fd926a0bf235c7921f12c84befb443", size = 17487553, upload-time = "2025-04-08T19:50:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/77/6d/131193b68d3884f9ab9474d916c6244df2914fbb3234d2a4c1fada72b1d6/ctranslate2-4.6.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:039aa6cc3ed662931a60dec0be28abeaaceb3cc6f476060b8017a7a39a54a9f6", size = 38817828, upload-time = "2025-04-08T19:50:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/d5/96/37470cbab08464a31877eb80c3ca3f56d097a1616adc982b53c5bf71d2c2/ctranslate2-4.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:af555c75cb9a9cc6c385f38680b92fa426761cf690e4479b1e962e2b17e02972", size = 19470232, upload-time = "2025-04-08T19:50:06.192Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "docopt" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" } + +[[package]] +name = "einops" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/81/df4fbe24dff8ba3934af99044188e20a98ed441ad17a274539b74e82e126/einops-0.8.1.tar.gz", hash = "sha256:de5d960a7a761225532e0f1959e5315ebeafc0cd43394732f103ca44b9837e84", size = 54805, upload-time = "2025-02-09T03:17:00.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload-time = "2025-02-09T03:17:01.998Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.116.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/b6/ed25b8874a27f684bf601990c48fcb3edb478edca2b9a38cc2ba196fb304/fastapi_cli-0.0.10.tar.gz", hash = "sha256:85a93df72ff834c3d2a356164512cabaf8f093d50eddad9309065a9c9ac5193a", size = 16994, upload-time = "2025-08-31T17:43:20.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/62/0f00036925c0614e333a2baf739c861453a6779331ffb47ec9a6147f860b/fastapi_cli-0.0.10-py3-none-any.whl", hash = "sha256:04bef56b49f7357c6c4acd4f793b4433ed3f511be431ed0af68db6d3f8bd44b3", size = 10851, upload-time = "2025-08-31T17:43:19.481Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/2e/3b6e5016affc310e5109bc580f760586eabecea0c8a7ab067611cd849ac0/fastapi_cloud_cli-0.1.5.tar.gz", hash = "sha256:341ee585eb731a6d3c3656cb91ad38e5f39809bf1a16d41de1333e38635a7937", size = 22710, upload-time = "2025-07-28T13:30:48.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/a6/5aa862489a2918a096166fd98d9fe86b7fd53c607678b3fa9d8c432d88d5/fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a", size = 18992, upload-time = "2025-07-28T13:30:47.427Z" }, +] + +[[package]] +name = "faster-whisper" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/c2/72002e5f80e73941de05f7b4347ea183d29f76768978a04acda68401c931/faster-whisper-1.2.0.tar.gz", hash = "sha256:56b20d616a575049a79f33b04f02db0868ce38c5d057a0b816d36ca59a6d2598", size = 1124896, upload-time = "2025-08-06T00:34:10.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/6d/64cdc135e4195f9473c2e42aa1d2268654be4c289223828eee8e6ba4fc6d/faster_whisper-1.2.0-py3-none-any.whl", hash = "sha256:e5535628fe93b5123029b410fd8edba2d28f8cee9f8fff8119138e5a9d81afbe", size = 1118581, upload-time = "2025-08-06T00:34:09.476Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.2.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/30/eb5dce7994fc71a2f685d98ec33cc660c0a5887db5610137e60d8cbc4489/flatbuffers-25.2.10.tar.gz", hash = "sha256:97e451377a41262f8d9bd4295cc836133415cc03d8cb966410a4af92eb00d26e", size = 22170, upload-time = "2025-02-11T04:26:46.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/25/155f9f080d5e4bc0082edfda032ea2bc2b8fab3f4d25d46c1e9dd22a1a89/flatbuffers-25.2.10-py2.py3-none-any.whl", hash = "sha256:ebba5f4d5ea615af3f7fd70fc310636fbb2bbd1f566ac0a23d98dd412de50051", size = 30953, upload-time = "2025-02-11T04:26:44.484Z" }, +] + +[[package]] +name = "fonttools" +version = "4.59.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/a5/fba25f9fbdab96e26dedcaeeba125e5f05a09043bf888e0305326e55685b/fonttools-4.59.2.tar.gz", hash = "sha256:e72c0749b06113f50bcb80332364c6be83a9582d6e3db3fe0b280f996dc2ef22", size = 3540889, upload-time = "2025-08-27T16:40:30.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/3d/1f45db2df51e7bfa55492e8f23f383d372200be3a0ded4bf56a92753dd1f/fonttools-4.59.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82906d002c349cad647a7634b004825a7335f8159d0d035ae89253b4abf6f3ea", size = 2769711, upload-time = "2025-08-27T16:39:04.423Z" }, + { url = "https://files.pythonhosted.org/packages/29/df/cd236ab32a8abfd11558f296e064424258db5edefd1279ffdbcfd4fd8b76/fonttools-4.59.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a10c1bd7644dc58f8862d8ba0cf9fb7fef0af01ea184ba6ce3f50ab7dfe74d5a", size = 2340225, upload-time = "2025-08-27T16:39:06.143Z" }, + { url = "https://files.pythonhosted.org/packages/98/12/b6f9f964fe6d4b4dd4406bcbd3328821c3de1f909ffc3ffa558fe72af48c/fonttools-4.59.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:738f31f23e0339785fd67652a94bc69ea49e413dfdb14dcb8c8ff383d249464e", size = 4912766, upload-time = "2025-08-27T16:39:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/82bde2f2d2c306ef3909b927363170b83df96171f74e0ccb47ad344563cd/fonttools-4.59.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ec99f9bdfee9cdb4a9172f9e8fd578cce5feb231f598909e0aecf5418da4f25", size = 4955178, upload-time = "2025-08-27T16:39:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/92/77/7de766afe2d31dda8ee46d7e479f35c7d48747e558961489a2d6e3a02bd4/fonttools-4.59.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0476ea74161322e08c7a982f83558a2b81b491509984523a1a540baf8611cc31", size = 4897898, upload-time = "2025-08-27T16:39:12.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/77/ce0e0b905d62a06415fda9f2b2e109a24a5db54a59502b769e9e297d2242/fonttools-4.59.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95922a922daa1f77cc72611747c156cfb38030ead72436a2c551d30ecef519b9", size = 5049144, upload-time = "2025-08-27T16:39:13.84Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/870d93aefd23fff2e07cbeebdc332527868422a433c64062c09d4d5e7fe6/fonttools-4.59.2-cp312-cp312-win32.whl", hash = "sha256:39ad9612c6a622726a6a130e8ab15794558591f999673f1ee7d2f3d30f6a3e1c", size = 2206473, upload-time = "2025-08-27T16:39:15.854Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/e44bad000c4a4bb2e9ca11491d266e857df98ab6d7428441b173f0fe2517/fonttools-4.59.2-cp312-cp312-win_amd64.whl", hash = "sha256:980fd7388e461b19a881d35013fec32c713ffea1fc37aef2f77d11f332dfd7da", size = 2254706, upload-time = "2025-08-27T16:39:17.893Z" }, + { url = "https://files.pythonhosted.org/packages/13/7b/d0d3b9431642947b5805201fbbbe938a47b70c76685ef1f0cb5f5d7140d6/fonttools-4.59.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:381bde13216ba09489864467f6bc0c57997bd729abfbb1ce6f807ba42c06cceb", size = 2761563, upload-time = "2025-08-27T16:39:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/fc5fe58dd76af7127b769b68071dbc32d4b95adc8b58d1d28d42d93c90f2/fonttools-4.59.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f33839aa091f7eef4e9078f5b7ab1b8ea4b1d8a50aeaef9fdb3611bba80869ec", size = 2335671, upload-time = "2025-08-27T16:39:22.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/bf231c2a3fac99d1d7f1d89c76594f158693f981a4aa02be406e9f036832/fonttools-4.59.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6235fc06bcbdb40186f483ba9d5d68f888ea68aa3c8dac347e05a7c54346fbc8", size = 4893967, upload-time = "2025-08-27T16:39:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/26/a9/d46d2ad4fcb915198504d6727f83aa07f46764c64f425a861aa38756c9fd/fonttools-4.59.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83ad6e5d06ef3a2884c4fa6384a20d6367b5cfe560e3b53b07c9dc65a7020e73", size = 4951986, upload-time = "2025-08-27T16:39:25.379Z" }, + { url = "https://files.pythonhosted.org/packages/07/90/1cc8d7dd8f707dfeeca472b82b898d3add0ebe85b1f645690dcd128ee63f/fonttools-4.59.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d029804c70fddf90be46ed5305c136cae15800a2300cb0f6bba96d48e770dde0", size = 4891630, upload-time = "2025-08-27T16:39:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/d8/04/f0345b0d9fe67d65aa8d3f2d4cbf91d06f111bc7b8d802e65914eb06194d/fonttools-4.59.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:95807a3b5e78f2714acaa26a33bc2143005cc05c0217b322361a772e59f32b89", size = 5035116, upload-time = "2025-08-27T16:39:29.406Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7d/5ba5eefffd243182fbd067cdbfeb12addd4e5aec45011b724c98a344ea33/fonttools-4.59.2-cp313-cp313-win32.whl", hash = "sha256:b3ebda00c3bb8f32a740b72ec38537d54c7c09f383a4cfefb0b315860f825b08", size = 2204907, upload-time = "2025-08-27T16:39:31.42Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a9/be7219fc64a6026cc0aded17fa3720f9277001c185434230bd351bf678e6/fonttools-4.59.2-cp313-cp313-win_amd64.whl", hash = "sha256:a72155928d7053bbde499d32a9c77d3f0f3d29ae72b5a121752481bcbd71e50f", size = 2253742, upload-time = "2025-08-27T16:39:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/486580d00be6fa5d45e41682e5ffa5c809f3d25773c6f39628d60f333521/fonttools-4.59.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d09e487d6bfbe21195801323ba95c91cb3523f0fcc34016454d4d9ae9eaa57fe", size = 2762444, upload-time = "2025-08-27T16:39:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9b/950ea9b7b764ceb8d18645c62191e14ce62124d8e05cb32a4dc5e65fde0b/fonttools-4.59.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dec2f22486d7781087b173799567cffdcc75e9fb2f1c045f05f8317ccce76a3e", size = 2333256, upload-time = "2025-08-27T16:39:40.777Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4d/8ee9d563126de9002eede950cde0051be86cc4e8c07c63eca0c9fc95734a/fonttools-4.59.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1647201af10993090120da2e66e9526c4e20e88859f3e34aa05b8c24ded2a564", size = 4834846, upload-time = "2025-08-27T16:39:42.885Z" }, + { url = "https://files.pythonhosted.org/packages/03/26/f26d947b0712dce3d118e92ce30ca88f98938b066498f60d0ee000a892ae/fonttools-4.59.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47742c33fe65f41eabed36eec2d7313a8082704b7b808752406452f766c573fc", size = 4930871, upload-time = "2025-08-27T16:39:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/ebe878061a5a5e6b6502f0548489e01100f7e6c0049846e6546ba19a3ab4/fonttools-4.59.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92ac2d45794f95d1ad4cb43fa07e7e3776d86c83dc4b9918cf82831518165b4b", size = 4876971, upload-time = "2025-08-27T16:39:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0d/0d22e3a20ac566836098d30718092351935487e3271fd57385db1adb2fde/fonttools-4.59.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fa9ecaf2dcef8941fb5719e16322345d730f4c40599bbf47c9753de40eb03882", size = 4987478, upload-time = "2025-08-27T16:39:48.774Z" }, + { url = "https://files.pythonhosted.org/packages/3b/a3/960cc83182a408ffacc795e61b5f698c6f7b0cfccf23da4451c39973f3c8/fonttools-4.59.2-cp314-cp314-win32.whl", hash = "sha256:a8d40594982ed858780e18a7e4c80415af65af0f22efa7de26bdd30bf24e1e14", size = 2208640, upload-time = "2025-08-27T16:39:50.592Z" }, + { url = "https://files.pythonhosted.org/packages/d8/74/55e5c57c414fa3965fee5fc036ed23f26a5c4e9e10f7f078a54ff9c7dfb7/fonttools-4.59.2-cp314-cp314-win_amd64.whl", hash = "sha256:9cde8b6a6b05f68516573523f2013a3574cb2c75299d7d500f44de82ba947b80", size = 2258457, upload-time = "2025-08-27T16:39:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/8e4261dc591c5cfee68fecff3ffee2a9b29e1edc4c4d9cbafdc5aefe74ee/fonttools-4.59.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:036cd87a2dbd7ef72f7b68df8314ced00b8d9973aee296f2464d06a836aeb9a9", size = 2829901, upload-time = "2025-08-27T16:39:55.014Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/331538dcf21fd6331579cd628268150e85210d0d2bdae20f7598c2b36c05/fonttools-4.59.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:14870930181493b1d740b6f25483e20185e5aea58aec7d266d16da7be822b4bb", size = 2362717, upload-time = "2025-08-27T16:39:56.843Z" }, + { url = "https://files.pythonhosted.org/packages/60/ae/d26428ca9ede809c0a93f0af91f44c87433dc0251e2aec333da5ed00d38f/fonttools-4.59.2-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ff58ea1eb8fc7e05e9a949419f031890023f8785c925b44d6da17a6a7d6e85d", size = 4835120, upload-time = "2025-08-27T16:39:59.06Z" }, + { url = "https://files.pythonhosted.org/packages/07/c4/0f6ac15895de509e07688cb1d45f1ae583adbaa0fa5a5699d73f3bd58ca0/fonttools-4.59.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dee142b8b3096514c96ad9e2106bf039e2fe34a704c587585b569a36df08c3c", size = 5071115, upload-time = "2025-08-27T16:40:01.009Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b6/147a711b7ecf7ea39f9da9422a55866f6dd5747c2f36b3b0a7a7e0c6820b/fonttools-4.59.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8991bdbae39cf78bcc9cd3d81f6528df1f83f2e7c23ccf6f990fa1f0b6e19708", size = 4943905, upload-time = "2025-08-27T16:40:03.179Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4e/2ab19006646b753855e2b02200fa1cabb75faa4eeca4ef289f269a936974/fonttools-4.59.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:53c1a411b7690042535a4f0edf2120096a39a506adeb6c51484a232e59f2aa0c", size = 4960313, upload-time = "2025-08-27T16:40:05.45Z" }, + { url = "https://files.pythonhosted.org/packages/98/3d/df77907e5be88adcca93cc2cee00646d039da220164be12bee028401e1cf/fonttools-4.59.2-cp314-cp314t-win32.whl", hash = "sha256:59d85088e29fa7a8f87d19e97a1beae2a35821ee48d8ef6d2c4f965f26cb9f8a", size = 2269719, upload-time = "2025-08-27T16:40:07.553Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/d4c4bc5b50275449a9a908283b567caa032a94505fe1976e17f994faa6be/fonttools-4.59.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7ad5d8d8cc9e43cb438b3eb4a0094dd6d4088daa767b0a24d52529361fd4c199", size = 2333169, upload-time = "2025-08-27T16:40:09.656Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/d2f7be3c86708912c02571db0b550121caab8cd88a3c0aacb9cfa15ea66e/fonttools-4.59.2-py3-none-any.whl", hash = "sha256:8bd0f759020e87bb5d323e6283914d9bf4ae35a7307dafb2cbd1e379e720ad37", size = 1132315, upload-time = "2025-08-27T16:40:28.984Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" }, + { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" }, + { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" }, + { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" }, + { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" }, + { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" }, + { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" }, + { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" }, + { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" }, + { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" }, + { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" }, + { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" }, + { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" }, + { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, +] + +[[package]] +name = "fsspec" +version = "2025.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847, upload-time = "2025-09-02T19:10:49.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" }, +] + +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[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 = "hf-xet" +version = "1.1.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/0f/5b60fc28ee7f8cc17a5114a584fd6b86e11c3e0a6e142a7f97a161e9640a/hf_xet-1.1.9.tar.gz", hash = "sha256:c99073ce404462e909f1d5839b2d14a3827b8fe75ed8aed551ba6609c026c803", size = 484242, upload-time = "2025-08-27T23:05:19.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/12/56e1abb9a44cdef59a411fe8a8673313195711b5ecce27880eb9c8fa90bd/hf_xet-1.1.9-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a3b6215f88638dd7a6ff82cb4e738dcbf3d863bf667997c093a3c990337d1160", size = 2762553, upload-time = "2025-08-27T23:05:15.153Z" }, + { url = "https://files.pythonhosted.org/packages/3a/e6/2d0d16890c5f21b862f5df3146519c182e7f0ae49b4b4bf2bd8a40d0b05e/hf_xet-1.1.9-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:9b486de7a64a66f9a172f4b3e0dfe79c9f0a93257c501296a2521a13495a698a", size = 2623216, upload-time = "2025-08-27T23:05:13.778Z" }, + { url = "https://files.pythonhosted.org/packages/81/42/7e6955cf0621e87491a1fb8cad755d5c2517803cea174229b0ec00ff0166/hf_xet-1.1.9-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c5a840c2c4e6ec875ed13703a60e3523bc7f48031dfd750923b2a4d1a5fc3c", size = 3186789, upload-time = "2025-08-27T23:05:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/df/8b/759233bce05457f5f7ec062d63bbfd2d0c740b816279eaaa54be92aa452a/hf_xet-1.1.9-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:96a6139c9e44dad1c52c52520db0fffe948f6bce487cfb9d69c125f254bb3790", size = 3088747, upload-time = "2025-08-27T23:05:10.439Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3c/28cc4db153a7601a996985bcb564f7b8f5b9e1a706c7537aad4b4809f358/hf_xet-1.1.9-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ad1022e9a998e784c97b2173965d07fe33ee26e4594770b7785a8cc8f922cd95", size = 3251429, upload-time = "2025-08-27T23:05:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/84/17/7caf27a1d101bfcb05be85850d4aa0a265b2e1acc2d4d52a48026ef1d299/hf_xet-1.1.9-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86754c2d6d5afb11b0a435e6e18911a4199262fe77553f8c50d75e21242193ea", size = 3354643, upload-time = "2025-08-27T23:05:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/cd/50/0c39c9eed3411deadcc98749a6699d871b822473f55fe472fad7c01ec588/hf_xet-1.1.9-cp37-abi3-win_amd64.whl", hash = "sha256:5aad3933de6b725d61d51034e04174ed1dce7a57c63d530df0014dea15a40127", size = 2804797, upload-time = "2025-08-27T23:05:20.77Z" }, +] + +[[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.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, +] + +[[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 = "huggingface-hub" +version = "0.34.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/c9/bdbe19339f76d12985bc03572f330a01a93c04dffecaaea3061bdd7fb892/huggingface_hub-0.34.4.tar.gz", hash = "sha256:a4228daa6fb001be3f4f4bdaf9a0db00e1739235702848df00885c9b5742c85c", size = 459768, upload-time = "2025-08-08T09:14:52.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/7b/bb06b061991107cd8783f300adff3e7b7f284e330fd82f507f2a1417b11d/huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a", size = 561452, upload-time = "2025-08-08T09:14:50.159Z" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "hyperpyyaml" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "ruamel-yaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/e3/3ac46d9a662b037f699a6948b39c8d03bfcff0b592335d5953ba0c55d453/HyperPyYAML-1.2.2.tar.gz", hash = "sha256:bdb734210d18770a262f500fe5755c7a44a5d3b91521b06e24f7a00a36ee0f87", size = 17085, upload-time = "2023-09-21T14:45:27.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/c9/751b6401887f4b50f9307cc1e53d287b3dc77c375c126aeb6335aff73ccb/HyperPyYAML-1.2.2-py3-none-any.whl", hash = "sha256:3c5864bdc8864b2f0fbd7bc495e7e8fdf2dfd5dd80116f72da27ca96a128bdeb", size = 16118, upload-time = "2023-09-21T14:45:25.101Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/5d/447af5ea094b9e4c4054f82e223ada074c552335b9b4b2d14bd9b35a67c4/joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55", size = 331077, upload-time = "2025-08-27T12:15:46.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241", size = 308396, upload-time = "2025-08-27T12:15:45.188Z" }, +] + +[[package]] +name = "julius" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/19/c9e1596b5572c786b93428d0904280e964c930fae7e6c9368ed9e1b63922/julius-0.2.7.tar.gz", hash = "sha256:3c0f5f5306d7d6016fcc95196b274cae6f07e2c9596eed314e4e7641554fbb08", size = 59640, upload-time = "2022-09-19T16:13:34.2Z" } + +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, + { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, + { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, + { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" }, +] + +[[package]] +name = "librosa" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "audioread" }, + { name = "decorator" }, + { name = "joblib" }, + { name = "lazy-loader" }, + { name = "msgpack" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pooch" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "soundfile" }, + { name = "soxr" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c4/22a644b91098223d653993388daaf9af28175f2f39073269efa6f7c71caf/librosa-0.10.1.tar.gz", hash = "sha256:832f7d150d6dd08ed2aa08c0567a4be58330635c32ddd2208de9bc91300802c7", size = 311110, upload-time = "2023-08-16T13:52:20.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/a2/4f639c1168d7aada749a896afb4892a831e2041bebdcf636aebfe9e86556/librosa-0.10.1-py3-none-any.whl", hash = "sha256:7ab91d9f5fcb75ea14848a05d3b1f825cf8d0c42ca160d19ae6874f2de2d8223", size = 253710, upload-time = "2023-08-16T13:52:19.141Z" }, +] + +[[package]] +name = "lightning" +version = "2.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pytorch-lightning" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/dd/86bb3bebadcdbc6e6e5a63657f0a03f74cd065b5ea965896679f76fec0b4/lightning-2.5.5.tar.gz", hash = "sha256:4d3d66c5b1481364a7e6a1ce8ddde1777a04fa740a3145ec218a9941aed7dd30", size = 640770, upload-time = "2025-09-05T16:01:21.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/d0/4b4fbafc3b18df91207a6e46782d9fd1905f9f45cb2c3b8dfbb239aef781/lightning-2.5.5-py3-none-any.whl", hash = "sha256:69eb248beadd7b600bf48eff00a0ec8af171ec7a678d23787c4aedf12e225e8f", size = 828490, upload-time = "2025-09-05T16:01:17.845Z" }, +] + +[[package]] +name = "lightning-utilities" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/39/6fc58ca81492db047149b4b8fd385aa1bfb8c28cd7cacb0c7eb0c44d842f/lightning_utilities-0.15.2.tar.gz", hash = "sha256:cdf12f530214a63dacefd713f180d1ecf5d165338101617b4742e8f22c032e24", size = 31090, upload-time = "2025-08-06T13:57:39.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/73/3d757cb3fc16f0f9794dd289bcd0c4a031d9cf54d8137d6b984b2d02edf3/lightning_utilities-0.15.2-py3-none-any.whl", hash = "sha256:ad3ab1703775044bbf880dbf7ddaaac899396c96315f3aa1779cec9d618a9841", size = 29431, upload-time = "2025-08-06T13:57:38.046Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880, upload-time = "2025-01-20T11:14:41.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/86/e3c3195b92e6e492458f16d233e58a1a812aa2bfbef9bdd0fbafcec85c60/llvmlite-0.44.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:1d671a56acf725bf1b531d5ef76b86660a5ab8ef19bb6a46064a705c6ca80aad", size = 28132297, upload-time = "2025-01-20T11:13:32.57Z" }, + { url = "https://files.pythonhosted.org/packages/d6/53/373b6b8be67b9221d12b24125fd0ec56b1078b660eeae266ec388a6ac9a0/llvmlite-0.44.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f79a728e0435493611c9f405168682bb75ffd1fbe6fc360733b850c80a026db", size = 26201105, upload-time = "2025-01-20T11:13:38.744Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/8341fd3056419441286c8e26bf436923021005ece0bff5f41906476ae514/llvmlite-0.44.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0143a5ef336da14deaa8ec26c5449ad5b6a2b564df82fcef4be040b9cacfea9", size = 42361901, upload-time = "2025-01-20T11:13:46.711Z" }, + { url = "https://files.pythonhosted.org/packages/53/ad/d79349dc07b8a395a99153d7ce8b01d6fcdc9f8231355a5df55ded649b61/llvmlite-0.44.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d752f89e31b66db6f8da06df8b39f9b91e78c5feea1bf9e8c1fba1d1c24c065d", size = 41184247, upload-time = "2025-01-20T11:13:56.159Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/a9a17366af80127bd09decbe2a54d8974b6d8b274b39bf47fbaedeec6307/llvmlite-0.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:eae7e2d4ca8f88f89d315b48c6b741dcb925d6a1042da694aa16ab3dd4cbd3a1", size = 30332380, upload-time = "2025-01-20T11:14:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/89/24/4c0ca705a717514c2092b18476e7a12c74d34d875e05e4d742618ebbf449/llvmlite-0.44.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:319bddd44e5f71ae2689859b7203080716448a3cd1128fb144fe5c055219d516", size = 28132306, upload-time = "2025-01-20T11:14:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/01/cf/1dd5a60ba6aee7122ab9243fd614abcf22f36b0437cbbe1ccf1e3391461c/llvmlite-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c58867118bad04a0bb22a2e0068c693719658105e40009ffe95c7000fcde88e", size = 26201090, upload-time = "2025-01-20T11:14:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/656f5a357de7135a3777bd735cc7c9b8f23b4d37465505bd0eaf4be9befe/llvmlite-0.44.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46224058b13c96af1365290bdfebe9a6264ae62fb79b2b55693deed11657a8bf", size = 42361904, upload-time = "2025-01-20T11:14:22.949Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e1/12c5f20cb9168fb3464a34310411d5ad86e4163c8ff2d14a2b57e5cc6bac/llvmlite-0.44.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0097052c32bf721a4efc03bd109d335dfa57d9bffb3d4c24cc680711b8b4fc", size = 41184245, upload-time = "2025-01-20T11:14:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/1a/7042f7430055d567cc3257ac409fcf608599ab27459457f13772c2d9778b/matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347", size = 8272404, upload-time = "2025-08-30T00:12:59.112Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5d/1d5f33f5b43f4f9e69e6a5fe1fb9090936ae7bc8e2ff6158e7a76542633b/matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75", size = 8128262, upload-time = "2025-08-30T00:13:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/67/c3/135fdbbbf84e0979712df58e5e22b4f257b3f5e52a3c4aacf1b8abec0d09/matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95", size = 8697008, upload-time = "2025-08-30T00:13:03.24Z" }, + { url = "https://files.pythonhosted.org/packages/9c/be/c443ea428fb2488a3ea7608714b1bd85a82738c45da21b447dc49e2f8e5d/matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb", size = 9530166, upload-time = "2025-08-30T00:13:05.951Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/48441422b044d74034aea2a3e0d1a49023f12150ebc58f16600132b9bbaf/matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07", size = 9593105, upload-time = "2025-08-30T00:13:08.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/c3/994ef20eb4154ab84cc08d033834555319e4af970165e6c8894050af0b3c/matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b", size = 8122784, upload-time = "2025-08-30T00:13:10.367Z" }, + { url = "https://files.pythonhosted.org/packages/57/b8/5c85d9ae0e40f04e71bedb053aada5d6bab1f9b5399a0937afb5d6b02d98/matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa", size = 7992823, upload-time = "2025-08-30T00:13:12.24Z" }, + { url = "https://files.pythonhosted.org/packages/a0/db/18380e788bb837e724358287b08e223b32bc8dccb3b0c12fa8ca20bc7f3b/matplotlib-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:819e409653c1106c8deaf62e6de6b8611449c2cd9939acb0d7d4e57a3d95cc7a", size = 8273231, upload-time = "2025-08-30T00:13:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/38dd49445b297e0d4f12a322c30779df0d43cb5873c7847df8a82e82ec67/matplotlib-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:59c8ac8382fefb9cb71308dde16a7c487432f5255d8f1fd32473523abecfecdf", size = 8128730, upload-time = "2025-08-30T00:13:15.556Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b8/9eea6630198cb303d131d95d285a024b3b8645b1763a2916fddb44ca8760/matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84e82d9e0fd70c70bc55739defbd8055c54300750cbacf4740c9673a24d6933a", size = 8698539, upload-time = "2025-08-30T00:13:17.297Z" }, + { url = "https://files.pythonhosted.org/packages/71/34/44c7b1f075e1ea398f88aeabcc2907c01b9cc99e2afd560c1d49845a1227/matplotlib-3.10.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25f7a3eb42d6c1c56e89eacd495661fc815ffc08d9da750bca766771c0fd9110", size = 9529702, upload-time = "2025-08-30T00:13:19.248Z" }, + { url = "https://files.pythonhosted.org/packages/b5/7f/e5c2dc9950c7facaf8b461858d1b92c09dd0cf174fe14e21953b3dda06f7/matplotlib-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9c862d91ec0b7842920a4cfdaaec29662195301914ea54c33e01f1a28d014b2", size = 9593742, upload-time = "2025-08-30T00:13:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1d/70c28528794f6410ee2856cd729fa1f1756498b8d3126443b0a94e1a8695/matplotlib-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:1b53bd6337eba483e2e7d29c5ab10eee644bc3a2491ec67cc55f7b44583ffb18", size = 8122753, upload-time = "2025-08-30T00:13:23.44Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/0e1670501fc7d02d981564caf7c4df42974464625935424ca9654040077c/matplotlib-3.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:cbd5eb50b7058b2892ce45c2f4e92557f395c9991f5c886d1bb74a1582e70fd6", size = 7992973, upload-time = "2025-08-30T00:13:26.632Z" }, + { url = "https://files.pythonhosted.org/packages/b1/4e/60780e631d73b6b02bd7239f89c451a72970e5e7ec34f621eda55cd9a445/matplotlib-3.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:acc86dd6e0e695c095001a7fccff158c49e45e0758fdf5dcdbb0103318b59c9f", size = 8316869, upload-time = "2025-08-30T00:13:28.262Z" }, + { url = "https://files.pythonhosted.org/packages/f8/15/baa662374a579413210fc2115d40c503b7360a08e9cc254aa0d97d34b0c1/matplotlib-3.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e228cd2ffb8f88b7d0b29e37f68ca9aaf83e33821f24a5ccc4f082dd8396bc27", size = 8178240, upload-time = "2025-08-30T00:13:30.007Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3f/3c38e78d2aafdb8829fcd0857d25aaf9e7dd2dfcf7ec742765b585774931/matplotlib-3.10.6-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:658bc91894adeab669cf4bb4a186d049948262987e80f0857216387d7435d833", size = 8711719, upload-time = "2025-08-30T00:13:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/96/4b/2ec2bbf8cefaa53207cc56118d1fa8a0f9b80642713ea9390235d331ede4/matplotlib-3.10.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8913b7474f6dd83ac444c9459c91f7f0f2859e839f41d642691b104e0af056aa", size = 9541422, upload-time = "2025-08-30T00:13:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/83/7d/40255e89b3ef11c7871020563b2dd85f6cb1b4eff17c0f62b6eb14c8fa80/matplotlib-3.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:091cea22e059b89f6d7d1a18e2c33a7376c26eee60e401d92a4d6726c4e12706", size = 9594068, upload-time = "2025-08-30T00:13:35.833Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a9/0213748d69dc842537a113493e1c27daf9f96bd7cc316f933dc8ec4de985/matplotlib-3.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:491e25e02a23d7207629d942c666924a6b61e007a48177fdd231a0097b7f507e", size = 8200100, upload-time = "2025-08-30T00:13:37.668Z" }, + { url = "https://files.pythonhosted.org/packages/be/15/79f9988066ce40b8a6f1759a934ea0cde8dc4adc2262255ee1bc98de6ad0/matplotlib-3.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3d80d60d4e54cda462e2cd9a086d85cd9f20943ead92f575ce86885a43a565d5", size = 8042142, upload-time = "2025-08-30T00:13:39.426Z" }, + { url = "https://files.pythonhosted.org/packages/7c/58/e7b6d292beae6fb4283ca6fb7fa47d7c944a68062d6238c07b497dd35493/matplotlib-3.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:70aaf890ce1d0efd482df969b28a5b30ea0b891224bb315810a3940f67182899", size = 8273802, upload-time = "2025-08-30T00:13:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f6/7882d05aba16a8cdd594fb9a03a9d3cca751dbb6816adf7b102945522ee9/matplotlib-3.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1565aae810ab79cb72e402b22facfa6501365e73ebab70a0fdfb98488d2c3c0c", size = 8131365, upload-time = "2025-08-30T00:13:42.664Z" }, + { url = "https://files.pythonhosted.org/packages/94/bf/ff32f6ed76e78514e98775a53715eca4804b12bdcf35902cdd1cf759d324/matplotlib-3.10.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b23315a01981689aa4e1a179dbf6ef9fbd17143c3eea77548c2ecfb0499438", size = 9533961, upload-time = "2025-08-30T00:13:44.372Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/6bf88c2fc2da7708a2ff8d2eeb5d68943130f50e636d5d3dcf9d4252e971/matplotlib-3.10.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30fdd37edf41a4e6785f9b37969de57aea770696cb637d9946eb37470c94a453", size = 9804262, upload-time = "2025-08-30T00:13:46.614Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7a/e05e6d9446d2d577b459427ad060cd2de5742d0e435db3191fea4fcc7e8b/matplotlib-3.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc31e693da1c08012c764b053e702c1855378e04102238e6a5ee6a7117c53a47", size = 9595508, upload-time = "2025-08-30T00:13:48.731Z" }, + { url = "https://files.pythonhosted.org/packages/39/fb/af09c463ced80b801629fd73b96f726c9f6124c3603aa2e480a061d6705b/matplotlib-3.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:05be9bdaa8b242bc6ff96330d18c52f1fc59c6fb3a4dd411d953d67e7e1baf98", size = 8252742, upload-time = "2025-08-30T00:13:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f9/b682f6db9396d9ab8f050c0a3bfbb5f14fb0f6518f08507c04cc02f8f229/matplotlib-3.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:f56a0d1ab05d34c628592435781d185cd99630bdfd76822cd686fb5a0aecd43a", size = 8124237, upload-time = "2025-08-30T00:13:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d2/b69b4a0923a3c05ab90527c60fdec899ee21ca23ede7f0fb818e6620d6f2/matplotlib-3.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:94f0b4cacb23763b64b5dace50d5b7bfe98710fed5f0cef5c08135a03399d98b", size = 8316956, upload-time = "2025-08-30T00:13:55.932Z" }, + { url = "https://files.pythonhosted.org/packages/28/e9/dc427b6f16457ffaeecb2fc4abf91e5adb8827861b869c7a7a6d1836fa73/matplotlib-3.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cc332891306b9fb39462673d8225d1b824c89783fee82840a709f96714f17a5c", size = 8178260, upload-time = "2025-08-30T00:14:00.942Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/1fbd5ad611802c34d1c7ad04607e64a1350b7fb9c567c4ec2c19e066ed35/matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee1d607b3fb1590deb04b69f02ea1d53ed0b0bf75b2b1a5745f269afcbd3cdd3", size = 9541422, upload-time = "2025-08-30T00:14:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/65fec8716025b22c1d72d5a82ea079934c76a547696eaa55be6866bc89b1/matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:376a624a218116461696b27b2bbf7a8945053e6d799f6502fc03226d077807bf", size = 9803678, upload-time = "2025-08-30T00:14:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b0/40fb2b3a1ab9381bb39a952e8390357c8be3bdadcf6d5055d9c31e1b35ae/matplotlib-3.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:83847b47f6524c34b4f2d3ce726bb0541c48c8e7692729865c3df75bfa0f495a", size = 9594077, upload-time = "2025-08-30T00:14:07.012Z" }, + { url = "https://files.pythonhosted.org/packages/76/34/c4b71b69edf5b06e635eee1ed10bfc73cf8df058b66e63e30e6a55e231d5/matplotlib-3.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c7e0518e0d223683532a07f4b512e2e0729b62674f1b3a1a69869f98e6b1c7e3", size = 8342822, upload-time = "2025-08-30T00:14:09.041Z" }, + { url = "https://files.pythonhosted.org/packages/e8/62/aeabeef1a842b6226a30d49dd13e8a7a1e81e9ec98212c0b5169f0a12d83/matplotlib-3.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:4dd83e029f5b4801eeb87c64efd80e732452781c16a9cf7415b7b63ec8f374d7", size = 8172588, upload-time = "2025-08-30T00:14:11.166Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" }, + { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" }, + { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" }, + { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" }, + { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" }, + { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" }, + { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" }, + { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" }, + { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" }, + { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" }, +] + +[[package]] +name = "multidict" +version = "6.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" }, + { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" }, + { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" }, + { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" }, + { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" }, + { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" }, + { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" }, + { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" }, + { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" }, + { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" }, + { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" }, + { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" }, + { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" }, +] + +[[package]] +name = "networkx" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, +] + +[[package]] +name = "numba" +version = "0.61.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615, upload-time = "2025-04-09T02:58:07.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/a0/c6b7b9c615cfa3b98c4c63f4316e3f6b3bbe2387740277006551784218cd/numba-0.61.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:34fba9406078bac7ab052efbf0d13939426c753ad72946baaa5bf9ae0ebb8dd2", size = 2776626, upload-time = "2025-04-09T02:57:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/92/4a/fe4e3c2ecad72d88f5f8cd04e7f7cff49e718398a2fac02d2947480a00ca/numba-0.61.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ddce10009bc097b080fc96876d14c051cc0c7679e99de3e0af59014dab7dfe8", size = 2779287, upload-time = "2025-04-09T02:57:53.658Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2d/e518df036feab381c23a624dac47f8445ac55686ec7f11083655eb707da3/numba-0.61.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b1bb509d01f23d70325d3a5a0e237cbc9544dd50e50588bc581ba860c213546", size = 3885928, upload-time = "2025-04-09T02:57:55.206Z" }, + { url = "https://files.pythonhosted.org/packages/10/0f/23cced68ead67b75d77cfcca3df4991d1855c897ee0ff3fe25a56ed82108/numba-0.61.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48a53a3de8f8793526cbe330f2a39fe9a6638efcbf11bd63f3d2f9757ae345cd", size = 3577115, upload-time = "2025-04-09T02:57:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/68/1d/ddb3e704c5a8fb90142bf9dc195c27db02a08a99f037395503bfbc1d14b3/numba-0.61.2-cp312-cp312-win_amd64.whl", hash = "sha256:97cf4f12c728cf77c9c1d7c23707e4d8fb4632b46275f8f3397de33e5877af18", size = 2831929, upload-time = "2025-04-09T02:57:58.45Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f3/0fe4c1b1f2569e8a18ad90c159298d862f96c3964392a20d74fc628aee44/numba-0.61.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3a10a8fc9afac40b1eac55717cece1b8b1ac0b946f5065c89e00bde646b5b154", size = 2771785, upload-time = "2025-04-09T02:57:59.96Z" }, + { url = "https://files.pythonhosted.org/packages/e9/71/91b277d712e46bd5059f8a5866862ed1116091a7cb03bd2704ba8ebe015f/numba-0.61.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d3bcada3c9afba3bed413fba45845f2fb9cd0d2b27dd58a1be90257e293d140", size = 2773289, upload-time = "2025-04-09T02:58:01.435Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e0/5ea04e7ad2c39288c0f0f9e8d47638ad70f28e275d092733b5817cf243c9/numba-0.61.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbca73ad81fa196bd53dc12e3aaf1564ae036e0c125f237c7644fe64a4928ab", size = 3893918, upload-time = "2025-04-09T02:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/17/58/064f4dcb7d7e9412f16ecf80ed753f92297e39f399c905389688cf950b81/numba-0.61.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f154aaea625fb32cfbe3b80c5456d514d416fcdf79733dd69c0df3a11348e9e", size = 3584056, upload-time = "2025-04-09T02:58:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/af/a4/6d3a0f2d3989e62a18749e1e9913d5fa4910bbb3e3311a035baea6caf26d/numba-0.61.2-cp313-cp313-win_amd64.whl", hash = "sha256:59321215e2e0ac5fa928a8020ab00b8e57cda8a97384963ac0dfa4d4e6aa54e7", size = 2831846, upload-time = "2025-04-09T02:58:06.125Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.10.2.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.83" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.1.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.3.90" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.8.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.27.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/5b/4e4fff7bad39adf89f735f2bc87248c81db71205b62bcc0d5ca5b606b3c3/nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039", size = 322364134, upload-time = "2025-06-03T21:58:04.013Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, +] + +[[package]] +name = "omegaconf" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload-time = "2022-12-08T20:59:22.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.22.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/70/ca2a4d38a5deccd98caa145581becb20c53684f451e89eb3a39915620066/onnxruntime-1.22.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:a938d11c0dc811badf78e435daa3899d9af38abee950d87f3ab7430eb5b3cf5a", size = 34342883, upload-time = "2025-07-10T19:15:38.223Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/00b099b4d4f6223b610421080d0eed9327ef9986785c9141819bbba0d396/onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984cea2a02fcc5dfea44ade9aca9fe0f7a8a2cd6f77c258fc4388238618f3928", size = 14473861, upload-time = "2025-07-10T19:15:42.911Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/519828a5292a6ccd8d5cd6d2f72c6b36ea528a2ef68eca69647732539ffa/onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d39a530aff1ec8d02e365f35e503193991417788641b184f5b1e8c9a6d5ce8d", size = 16475713, upload-time = "2025-07-10T19:15:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/5d/54/7139d463bb0a312890c9a5db87d7815d4a8cce9e6f5f28d04f0b55fcb160/onnxruntime-1.22.1-cp312-cp312-win_amd64.whl", hash = "sha256:6a64291d57ea966a245f749eb970f4fa05a64d26672e05a83fdb5db6b7d62f87", size = 12690910, upload-time = "2025-07-10T19:15:47.478Z" }, + { url = "https://files.pythonhosted.org/packages/e0/39/77cefa829740bd830915095d8408dce6d731b244e24b1f64fe3df9f18e86/onnxruntime-1.22.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:d29c7d87b6cbed8fecfd09dca471832384d12a69e1ab873e5effbb94adc3e966", size = 34342026, upload-time = "2025-07-10T19:15:50.266Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/444291524cb52875b5de980a6e918072514df63a57a7120bf9dfae3aeed1/onnxruntime-1.22.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460487d83b7056ba98f1f7bac80287224c31d8149b15712b0d6f5078fcc33d0f", size = 14474014, upload-time = "2025-07-10T19:15:53.991Z" }, + { url = "https://files.pythonhosted.org/packages/87/9d/45a995437879c18beff26eacc2322f4227224d04c6ac3254dce2e8950190/onnxruntime-1.22.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0c37070268ba4e02a1a9d28560cd00cd1e94f0d4f275cbef283854f861a65fa", size = 16475427, upload-time = "2025-07-10T19:15:56.067Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/9c765e66ad32a7e709ce4cb6b95d7eaa9cb4d92a6e11ea97c20ffecaf765/onnxruntime-1.22.1-cp313-cp313-win_amd64.whl", hash = "sha256:70980d729145a36a05f74b573435531f55ef9503bcda81fc6c3d6b9306199982", size = 12690841, upload-time = "2025-07-10T19:15:58.337Z" }, + { url = "https://files.pythonhosted.org/packages/52/8c/02af24ee1c8dce4e6c14a1642a7a56cebe323d2fa01d9a360a638f7e4b75/onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a7980bbc4b7f446bac26c3785652fe8730ed02617d765399e89ac7d44e0f7d", size = 14479333, upload-time = "2025-07-10T19:16:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/5d/15/d75fd66aba116ce3732bb1050401394c5ec52074c4f7ee18db8838dd4667/onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7e823624b015ea879d976cbef8bfaed2f7e2cc233d7506860a76dd37f8f381", size = 16477261, upload-time = "2025-07-10T19:16:03.226Z" }, +] + +[[package]] +name = "optuna" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alembic" }, + { name = "colorlog" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/bcd1e5500de6ec794c085a277e5b624e60b4fac1790681d7cdbde25b93a2/optuna-4.5.0.tar.gz", hash = "sha256:264844da16dad744dea295057d8bc218646129c47567d52c35a201d9f99942ba", size = 472338, upload-time = "2025-08-18T06:49:22.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/12/cba81286cbaf0f0c3f0473846cfd992cb240bdcea816bf2ef7de8ed0f744/optuna-4.5.0-py3-none-any.whl", hash = "sha256:5b8a783e84e448b0742501bc27195344a28d2c77bd2feef5b558544d954851b0", size = 400872, upload-time = "2025-08-18T06:49:20.697Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, + { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/27/64/a2f7bf678af502e16b472527735d168b22b7824e45a4d7e96a4fbb634b59/pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e", size = 11531061, upload-time = "2025-08-21T10:27:34.647Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/c3d21b2b7769ef2f4c2b9299fcadd601efa6729f1357a8dbce8dd949ed70/pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9", size = 10668666, upload-time = "2025-08-21T10:27:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/f775ba76ecfb3424d7f5862620841cf0edb592e9abd2d2a5387d305fe7a8/pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a", size = 11332835, upload-time = "2025-08-21T10:27:40.188Z" }, + { url = "https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b", size = 12057211, upload-time = "2025-08-21T10:27:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9d/2df913f14b2deb9c748975fdb2491da1a78773debb25abbc7cbc67c6b549/pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6", size = 12749277, upload-time = "2025-08-21T10:27:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/da1a2417026bd14d98c236dba88e39837182459d29dcfcea510b2ac9e8a1/pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a", size = 13415256, upload-time = "2025-08-21T10:27:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/22/3c/f2af1ce8840ef648584a6156489636b5692c162771918aa95707c165ad2b/pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b", size = 10982579, upload-time = "2025-08-21T10:28:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/f3/98/8df69c4097a6719e357dc249bf437b8efbde808038268e584421696cbddf/pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57", size = 12028163, upload-time = "2025-08-21T10:27:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/f95cbcbea319f349e10ff90db488b905c6883f03cbabd34f6b03cbc3c044/pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2", size = 11391860, upload-time = "2025-08-21T10:27:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1b/6a984e98c4abee22058aa75bfb8eb90dce58cf8d7296f8bc56c14bc330b0/pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9", size = 11309830, upload-time = "2025-08-21T10:27:56.957Z" }, + { url = "https://files.pythonhosted.org/packages/15/d5/f0486090eb18dd8710bf60afeaf638ba6817047c0c8ae5c6a25598665609/pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2", size = 11883216, upload-time = "2025-08-21T10:27:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/10/86/692050c119696da19e20245bbd650d8dfca6ceb577da027c3a73c62a047e/pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012", size = 12699743, upload-time = "2025-08-21T10:28:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/612123674d7b17cf345aad0a10289b2a384bff404e0463a83c4a3a59d205/pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370", size = 13186141, upload-time = "2025-08-21T10:28:05.377Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "pooch" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/b3d3e00c696c16cf99af81ef7b1f5fe73bd2a307abca41bd7605429fe6e5/pooch-1.8.2.tar.gz", hash = "sha256:76561f0de68a01da4df6af38e9955c4c9d1a5c90da73f7e40276a5728ec83d10", size = 59353, upload-time = "2024-06-06T16:53:46.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/87/77cc11c7a9ea9fd05503def69e3d18605852cd0d4b0d3b8f15bbeb3ef1d1/pooch-1.8.2-py3-none-any.whl", hash = "sha256:3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47", size = 64574, upload-time = "2024-06-06T16:53:44.343Z" }, +] + +[[package]] +name = "primepy" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/77/0cfa1b4697cfb5336f3a96e8bc73327f64610be3a64c97275f1801afb395/primePy-1.3.tar.gz", hash = "sha256:25fd7e25344b0789a5984c75d89f054fcf1f180bef20c998e4befbac92de4669", size = 3914, upload-time = "2018-05-29T17:18:18.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload-time = "2018-05-29T17:18:17.53Z" }, +] + +[[package]] +name = "propcache" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" }, + { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" }, + { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" }, + { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" }, + { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" }, + { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" }, + { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" }, +] + +[[package]] +name = "protobuf" +version = "6.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/df/fb4a8eeea482eca989b51cffd274aac2ee24e825f0bf3cbce5281fa1567b/protobuf-6.32.0.tar.gz", hash = "sha256:a81439049127067fc49ec1d36e25c6ee1d1a2b7be930675f919258d03c04e7d2", size = 440614, upload-time = "2025-08-14T21:21:25.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/18/df8c87da2e47f4f1dcc5153a81cd6bca4e429803f4069a299e236e4dd510/protobuf-6.32.0-cp310-abi3-win32.whl", hash = "sha256:84f9e3c1ff6fb0308dbacb0950d8aa90694b0d0ee68e75719cb044b7078fe741", size = 424409, upload-time = "2025-08-14T21:21:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/e1/59/0a820b7310f8139bd8d5a9388e6a38e1786d179d6f33998448609296c229/protobuf-6.32.0-cp310-abi3-win_amd64.whl", hash = "sha256:a8bdbb2f009cfc22a36d031f22a625a38b615b5e19e558a7b756b3279723e68e", size = 435735, upload-time = "2025-08-14T21:21:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5b/0d421533c59c789e9c9894683efac582c06246bf24bb26b753b149bd88e4/protobuf-6.32.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d52691e5bee6c860fff9a1c86ad26a13afbeb4b168cd4445c922b7e2cf85aaf0", size = 426449, upload-time = "2025-08-14T21:21:16.687Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/607764ebe6c7a23dcee06e054fd1de3d5841b7648a90fd6def9a3bb58c5e/protobuf-6.32.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:501fe6372fd1c8ea2a30b4d9be8f87955a64d6be9c88a973996cef5ef6f0abf1", size = 322869, upload-time = "2025-08-14T21:21:18.282Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/2e730bd1c25392fc32e3268e02446f0d77cb51a2c3a8486b1798e34d5805/protobuf-6.32.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:75a2aab2bd1aeb1f5dc7c5f33bcb11d82ea8c055c9becbb41c26a8c43fd7092c", size = 322009, upload-time = "2025-08-14T21:21:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f2/80ffc4677aac1bc3519b26bc7f7f5de7fce0ee2f7e36e59e27d8beb32dd1/protobuf-6.32.0-py3-none-any.whl", hash = "sha256:ba377e5b67b908c8f3072a57b63e2c6a4cbd18aea4ed98d2584350dbf46f2783", size = 169287, upload-time = "2025-08-14T21:21:23.515Z" }, +] + +[[package]] +name = "pyannote-audio" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asteroid-filterbanks" }, + { name = "einops" }, + { name = "huggingface-hub" }, + { name = "lightning" }, + { name = "omegaconf" }, + { name = "pyannote-core" }, + { name = "pyannote-database" }, + { name = "pyannote-metrics" }, + { name = "pyannote-pipeline" }, + { name = "pytorch-metric-learning" }, + { name = "rich" }, + { name = "semver" }, + { name = "soundfile" }, + { name = "speechbrain" }, + { name = "tensorboardx" }, + { name = "torch" }, + { name = "torch-audiomentations" }, + { name = "torchaudio" }, + { name = "torchmetrics" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/55/7253267c35e2aa9188b1d86cba121eb5bdd91ed12d3194488625a008cae7/pyannote.audio-3.1.0.tar.gz", hash = "sha256:da04705443d3b74607e034d3ca88f8b572c7e9672dd9a4199cab65a0dbc33fad", size = 14812058, upload-time = "2023-11-16T12:26:38.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/37/158859ce4c45b5ba2dca40b53b0c10d36f935b7f6d4e737298397167c8b1/pyannote.audio-3.1.0-py2.py3-none-any.whl", hash = "sha256:66ab485728c6e141760e80555cb7a083e7be824cd528cc79b9e6f7d6421a91ae", size = 208592, upload-time = "2023-11-16T12:26:36.726Z" }, +] + +[[package]] +name = "pyannote-core" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, + { name = "sortedcontainers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/03/feaf7534206f02c75baf151ce4b8c322b402a6f477c2be82f69d9269cbe6/pyannote.core-5.0.0.tar.gz", hash = "sha256:1a55bcc8bd680ba6be5fa53efa3b6f3d2cdd67144c07b6b4d8d66d5cb0d2096f", size = 59247, upload-time = "2022-12-15T13:02:05.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/c4/370bc8ba66815a5832ece753a1009388bb07ea353d21c83f2d5a1a436f2c/pyannote.core-5.0.0-py3-none-any.whl", hash = "sha256:04920a6754492242ce0dc6017545595ab643870fe69a994f20c1a5f2da0544d0", size = 58475, upload-time = "2022-12-15T13:02:03.265Z" }, +] + +[[package]] +name = "pyannote-database" +version = "5.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pandas" }, + { name = "pyannote-core" }, + { name = "pyyaml" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/ae/de36413d69a46be87cb612ebbcdc4eacbeebce3bc809124603e44a88fe26/pyannote.database-5.1.3.tar.gz", hash = "sha256:0eaf64c1cc506718de60d2d702f1359b1ae7ff252ee3e4799f1c5e378cd52c31", size = 49957, upload-time = "2025-01-15T20:28:26.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/64/92d51a3a05615ba58be8ba62a43f9f9f952d9f3646f7e4fb7826e5a3a24e/pyannote.database-5.1.3-py3-none-any.whl", hash = "sha256:37887844c7dfbcc075cb591eddc00aff45fae1ed905344e1f43e0090e63bd40a", size = 48127, upload-time = "2025-01-15T20:28:25.326Z" }, +] + +[[package]] +name = "pyannote-metrics" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docopt" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pyannote-core" }, + { name = "pyannote-database" }, + { name = "scikit-learn" }, + { name = "scipy" }, + { name = "sympy" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/2b/6c5f01d3c49aa1c160765946e23782ca6436ae8b9bc514b56319ff5f16e7/pyannote.metrics-3.2.1.tar.gz", hash = "sha256:08024255a3550e96a8e9da4f5f4af326886548480de891414567c8900920ee5c", size = 49086, upload-time = "2022-06-20T14:10:34.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/7d/035b370ab834b30e849fe9cd092b7bd7f321fcc4a2c56b84e96476b7ede5/pyannote.metrics-3.2.1-py3-none-any.whl", hash = "sha256:46be797cdade26c82773e5018659ae610145260069c7c5bf3d3c8a029ade8e22", size = 51386, upload-time = "2022-06-20T14:10:32.621Z" }, +] + +[[package]] +name = "pyannote-pipeline" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docopt" }, + { name = "filelock" }, + { name = "optuna" }, + { name = "pyannote-core" }, + { name = "pyannote-database" }, + { name = "pyyaml" }, + { name = "scikit-learn" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/04/4bcfe0dd588577a188328b806f3a7213d8cead0ce5fe5784d01fd57df93f/pyannote.pipeline-3.0.1.tar.gz", hash = "sha256:021794e26a2cf5d8fb5bb1835951e71f5fac33eb14e23dfb7468e16b1b805151", size = 34486, upload-time = "2023-09-22T20:16:49.951Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/42/1bf7cbf061ed05c580bfb63bffdd3f3474cbd5c02bee4fac518eea9e9d9e/pyannote.pipeline-3.0.1-py3-none-any.whl", hash = "sha256:819bde4c4dd514f740f2373dfec794832b9fc8e346a35e43a7681625ee187393", size = 31517, upload-time = "2023-09-22T20:16:48.153Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +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/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pytorch-lightning" +version = "2.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/78/bce84aab9a5b3b2e9d087d4f1a6be9b481adbfaac4903bc9daaaf09d49a3/pytorch_lightning-2.5.5.tar.gz", hash = "sha256:d6fc8173d1d6e49abfd16855ea05d2eb2415e68593f33d43e59028ecb4e64087", size = 643703, upload-time = "2025-09-05T16:01:18.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/f6/99a5c66478f469598dee25b0e29b302b5bddd4e03ed0da79608ac964056e/pytorch_lightning-2.5.5-py3-none-any.whl", hash = "sha256:0b533991df2353c0c6ea9ca10a7d0728b73631fd61f5a15511b19bee2aef8af0", size = 832431, upload-time = "2025-09-05T16:01:16.234Z" }, +] + +[[package]] +name = "pytorch-metric-learning" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scikit-learn" }, + { name = "torch" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/80/6e61b1a91debf4c1b47d441f9a9d7fe2aabcdd9575ed70b2811474eb95c3/pytorch-metric-learning-2.9.0.tar.gz", hash = "sha256:27a626caf5e2876a0fd666605a78cb67ef7597e25d7a68c18053dd503830701f", size = 84530, upload-time = "2025-08-17T17:11:19.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/7d/73ef5052f57b7720cad00e16598db3592a5ef4826745ffca67a2f085d4dc/pytorch_metric_learning-2.9.0-py3-none-any.whl", hash = "sha256:d51646006dc87168f00cf954785db133a4c5aac81253877248737aa42ef6432a", size = 127801, upload-time = "2025-08-17T17:11:18.185Z" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "reflector-gpu" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi", extra = ["standard"] }, + { name = "faster-whisper" }, + { name = "librosa" }, + { name = "numpy" }, + { name = "pyannote-audio" }, + { name = "sentencepiece" }, + { name = "silero-vad" }, + { name = "torch" }, + { name = "torchaudio" }, + { name = "transformers" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", extras = ["standard"], specifier = ">=0.116.1" }, + { name = "faster-whisper", specifier = ">=1.1.0" }, + { name = "librosa", specifier = "==0.10.1" }, + { name = "numpy", specifier = "<2" }, + { name = "pyannote-audio", specifier = "==3.1.0" }, + { name = "sentencepiece" }, + { name = "silero-vad", specifier = "==5.1.0" }, + { name = "torch", specifier = ">=2.3.0" }, + { name = "torchaudio", specifier = ">=2.3.0" }, + { name = "transformers", specifier = ">=4.35.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] + +[[package]] +name = "regex" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/5a/4c63457fbcaf19d138d72b2e9b39405954f98c0349b31c601bfcb151582c/regex-2025.9.1.tar.gz", hash = "sha256:88ac07b38d20b54d79e704e38aa3bd2c0f8027432164226bdee201a1c0c9c9ff", size = 400852, upload-time = "2025-09-01T22:10:10.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ef/a0372febc5a1d44c1be75f35d7e5aff40c659ecde864d7fa10e138f75e74/regex-2025.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:84a25164bd8dcfa9f11c53f561ae9766e506e580b70279d05a7946510bdd6f6a", size = 486317, upload-time = "2025-09-01T22:08:34.529Z" }, + { url = "https://files.pythonhosted.org/packages/b5/25/d64543fb7eb41a1024786d518cc57faf1ce64aa6e9ddba097675a0c2f1d2/regex-2025.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:645e88a73861c64c1af558dd12294fb4e67b5c1eae0096a60d7d8a2143a611c7", size = 289698, upload-time = "2025-09-01T22:08:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/d8/dc/fbf31fc60be317bd9f6f87daa40a8a9669b3b392aa8fe4313df0a39d0722/regex-2025.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10a450cba5cd5409526ee1d4449f42aad38dd83ac6948cbd6d7f71ca7018f7db", size = 287242, upload-time = "2025-09-01T22:08:37.794Z" }, + { url = "https://files.pythonhosted.org/packages/0f/74/f933a607a538f785da5021acf5323961b4620972e2c2f1f39b6af4b71db7/regex-2025.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9dc5991592933a4192c166eeb67b29d9234f9c86344481173d1bc52f73a7104", size = 797441, upload-time = "2025-09-01T22:08:39.108Z" }, + { url = "https://files.pythonhosted.org/packages/89/d0/71fc49b4f20e31e97f199348b8c4d6e613e7b6a54a90eb1b090c2b8496d7/regex-2025.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a32291add816961aab472f4fad344c92871a2ee33c6c219b6598e98c1f0108f2", size = 862654, upload-time = "2025-09-01T22:08:40.586Z" }, + { url = "https://files.pythonhosted.org/packages/59/05/984edce1411a5685ba9abbe10d42cdd9450aab4a022271f9585539788150/regex-2025.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:588c161a68a383478e27442a678e3b197b13c5ba51dbba40c1ccb8c4c7bee9e9", size = 910862, upload-time = "2025-09-01T22:08:42.416Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/5c891bb5fe0691cc1bad336e3a94b9097fbcf9707ec8ddc1dce9f0397289/regex-2025.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47829ffaf652f30d579534da9085fe30c171fa2a6744a93d52ef7195dc38218b", size = 801991, upload-time = "2025-09-01T22:08:44.072Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ae/fd10d6ad179910f7a1b3e0a7fde1ef8bb65e738e8ac4fd6ecff3f52252e4/regex-2025.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e978e5a35b293ea43f140c92a3269b6ab13fe0a2bf8a881f7ac740f5a6ade85", size = 786651, upload-time = "2025-09-01T22:08:46.079Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/9d686b07bbc5bf94c879cc168db92542d6bc9fb67088d03479fef09ba9d3/regex-2025.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cf09903e72411f4bf3ac1eddd624ecfd423f14b2e4bf1c8b547b72f248b7bf7", size = 856556, upload-time = "2025-09-01T22:08:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/302f8a29bb8a49528abbab2d357a793e2a59b645c54deae0050f8474785b/regex-2025.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d016b0f77be63e49613c9e26aaf4a242f196cd3d7a4f15898f5f0ab55c9b24d2", size = 849001, upload-time = "2025-09-01T22:08:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/b4c6dbdedc85ef4caec54c817cd5f4418dbfa2453214119f2538082bf666/regex-2025.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:656563e620de6908cd1c9d4f7b9e0777e3341ca7db9d4383bcaa44709c90281e", size = 788138, upload-time = "2025-09-01T22:08:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1b/91ee17a3cbf87f81e8c110399279d0e57f33405468f6e70809100f2ff7d8/regex-2025.9.1-cp312-cp312-win32.whl", hash = "sha256:df33f4ef07b68f7ab637b1dbd70accbf42ef0021c201660656601e8a9835de45", size = 264524, upload-time = "2025-09-01T22:08:53.75Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/6ba31cce05b0f1ec6b787921903f83bd0acf8efde55219435572af83c350/regex-2025.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5aba22dfbc60cda7c0853516104724dc904caa2db55f2c3e6e984eb858d3edf3", size = 275489, upload-time = "2025-09-01T22:08:55.037Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ed/ea49f324db00196e9ef7fe00dd13c6164d5173dd0f1bbe495e61bb1fb09d/regex-2025.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:ec1efb4c25e1849c2685fa95da44bfde1b28c62d356f9c8d861d4dad89ed56e9", size = 268589, upload-time = "2025-09-01T22:08:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/98/25/b2959ce90c6138c5142fe5264ee1f9b71a0c502ca4c7959302a749407c79/regex-2025.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bc6834727d1b98d710a63e6c823edf6ffbf5792eba35d3fa119531349d4142ef", size = 485932, upload-time = "2025-09-01T22:08:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/6507a2a85f3f2be6643438b7bd976e67ad73223692d6988eb1ff444106d3/regex-2025.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c3dc05b6d579875719bccc5f3037b4dc80433d64e94681a0061845bd8863c025", size = 289568, upload-time = "2025-09-01T22:08:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d8/de4a4b57215d99868f1640e062a7907e185ec7476b4b689e2345487c1ff4/regex-2025.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22213527df4c985ec4a729b055a8306272d41d2f45908d7bacb79be0fa7a75ad", size = 286984, upload-time = "2025-09-01T22:09:00.835Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/e8cb403403a57ed316e80661db0e54d7aa2efcd85cb6156f33cc18746922/regex-2025.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e3f6e3c5a5a1adc3f7ea1b5aec89abfc2f4fbfba55dafb4343cd1d084f715b2", size = 797514, upload-time = "2025-09-01T22:09:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/2446f2b9585fed61faaa7e2bbce3aca7dd8df6554c32addee4c4caecf24a/regex-2025.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcb89c02a0d6c2bec9b0bb2d8c78782699afe8434493bfa6b4021cc51503f249", size = 862586, upload-time = "2025-09-01T22:09:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b8/82ffbe9c0992c31bbe6ae1c4b4e21269a5df2559102b90543c9b56724c3c/regex-2025.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0e2f95413eb0c651cd1516a670036315b91b71767af83bc8525350d4375ccba", size = 910815, upload-time = "2025-09-01T22:09:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d8/7303ea38911759c1ee30cc5bc623ee85d3196b733c51fd6703c34290a8d9/regex-2025.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a41dc039e1c97d3c2ed3e26523f748e58c4de3ea7a31f95e1cf9ff973fff5a", size = 802042, upload-time = "2025-09-01T22:09:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0e/6ad51a55ed4b5af512bb3299a05d33309bda1c1d1e1808fa869a0bed31bc/regex-2025.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f0b4258b161094f66857a26ee938d3fe7b8a5063861e44571215c44fbf0e5df", size = 786764, upload-time = "2025-09-01T22:09:09.362Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d5/394e3ffae6baa5a9217bbd14d96e0e5da47bb069d0dbb8278e2681a2b938/regex-2025.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bf70e18ac390e6977ea7e56f921768002cb0fa359c4199606c7219854ae332e0", size = 856557, upload-time = "2025-09-01T22:09:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/cd/80/b288d3910c41194ad081b9fb4b371b76b0bbfdce93e7709fc98df27b37dc/regex-2025.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b84036511e1d2bb0a4ff1aec26951caa2dea8772b223c9e8a19ed8885b32dbac", size = 849108, upload-time = "2025-09-01T22:09:12.877Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cd/5ec76bf626d0d5abdc277b7a1734696f5f3d14fbb4a3e2540665bc305d85/regex-2025.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c2e05dcdfe224047f2a59e70408274c325d019aad96227ab959403ba7d58d2d7", size = 788201, upload-time = "2025-09-01T22:09:14.561Z" }, + { url = "https://files.pythonhosted.org/packages/b5/36/674672f3fdead107565a2499f3007788b878188acec6d42bc141c5366c2c/regex-2025.9.1-cp313-cp313-win32.whl", hash = "sha256:3b9a62107a7441b81ca98261808fed30ae36ba06c8b7ee435308806bd53c1ed8", size = 264508, upload-time = "2025-09-01T22:09:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/83/ad/931134539515eb64ce36c24457a98b83c1b2e2d45adf3254b94df3735a76/regex-2025.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:b38afecc10c177eb34cfae68d669d5161880849ba70c05cbfbe409f08cc939d7", size = 275469, upload-time = "2025-09-01T22:09:17.462Z" }, + { url = "https://files.pythonhosted.org/packages/24/8c/96d34e61c0e4e9248836bf86d69cb224fd222f270fa9045b24e218b65604/regex-2025.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:ec329890ad5e7ed9fc292858554d28d58d56bf62cf964faf0aa57964b21155a0", size = 268586, upload-time = "2025-09-01T22:09:18.948Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/453cbea5323b049181ec6344a803777914074b9726c9c5dc76749966d12d/regex-2025.9.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:72fb7a016467d364546f22b5ae86c45680a4e0de6b2a6f67441d22172ff641f1", size = 486111, upload-time = "2025-09-01T22:09:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0e/92577f197bd2f7652c5e2857f399936c1876978474ecc5b068c6d8a79c86/regex-2025.9.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c9527fa74eba53f98ad86be2ba003b3ebe97e94b6eb2b916b31b5f055622ef03", size = 289520, upload-time = "2025-09-01T22:09:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/af/c6/b472398116cca7ea5a6c4d5ccd0fc543f7fd2492cb0c48d2852a11972f73/regex-2025.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c905d925d194c83a63f92422af7544ec188301451b292c8b487f0543726107ca", size = 287215, upload-time = "2025-09-01T22:09:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/cf/11/f12ecb0cf9ca792a32bb92f758589a84149017467a544f2f6bfb45c0356d/regex-2025.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74df7c74a63adcad314426b1f4ea6054a5ab25d05b0244f0c07ff9ce640fa597", size = 797855, upload-time = "2025-09-01T22:09:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/46/88/bbb848f719a540fb5997e71310f16f0b33a92c5d4b4d72d4311487fff2a3/regex-2025.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f6e935e98ea48c7a2e8be44494de337b57a204470e7f9c9c42f912c414cd6f5", size = 863363, upload-time = "2025-09-01T22:09:26.705Z" }, + { url = "https://files.pythonhosted.org/packages/54/a9/2321eb3e2838f575a78d48e03c1e83ea61bd08b74b7ebbdeca8abc50fc25/regex-2025.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4a62d033cd9ebefc7c5e466731a508dfabee827d80b13f455de68a50d3c2543d", size = 910202, upload-time = "2025-09-01T22:09:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/07/d1d70835d7d11b7e126181f316f7213c4572ecf5c5c97bdbb969fb1f38a2/regex-2025.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef971ebf2b93bdc88d8337238be4dfb851cc97ed6808eb04870ef67589415171", size = 801808, upload-time = "2025-09-01T22:09:30.733Z" }, + { url = "https://files.pythonhosted.org/packages/13/d1/29e4d1bed514ef2bf3a4ead3cb8bb88ca8af94130239a4e68aa765c35b1c/regex-2025.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d936a1db208bdca0eca1f2bb2c1ba1d8370b226785c1e6db76e32a228ffd0ad5", size = 786824, upload-time = "2025-09-01T22:09:32.61Z" }, + { url = "https://files.pythonhosted.org/packages/33/27/20d8ccb1bee460faaa851e6e7cc4cfe852a42b70caa1dca22721ba19f02f/regex-2025.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7e786d9e4469698fc63815b8de08a89165a0aa851720eb99f5e0ea9d51dd2b6a", size = 857406, upload-time = "2025-09-01T22:09:34.117Z" }, + { url = "https://files.pythonhosted.org/packages/74/fe/60c6132262dc36430d51e0c46c49927d113d3a38c1aba6a26c7744c84cf3/regex-2025.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6b81d7dbc5466ad2c57ce3a0ddb717858fe1a29535c8866f8514d785fdb9fc5b", size = 848593, upload-time = "2025-09-01T22:09:35.598Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ae/2d4ff915622fabbef1af28387bf71e7f2f4944a348b8460d061e85e29bf0/regex-2025.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cd4890e184a6feb0ef195338a6ce68906a8903a0f2eb7e0ab727dbc0a3156273", size = 787951, upload-time = "2025-09-01T22:09:37.139Z" }, + { url = "https://files.pythonhosted.org/packages/85/37/dc127703a9e715a284cc2f7dbdd8a9776fd813c85c126eddbcbdd1ca5fec/regex-2025.9.1-cp314-cp314-win32.whl", hash = "sha256:34679a86230e46164c9e0396b56cab13c0505972343880b9e705083cc5b8ec86", size = 269833, upload-time = "2025-09-01T22:09:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/4bed4d3d0570e16771defd5f8f15f7ea2311edcbe91077436d6908956c4a/regex-2025.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:a1196e530a6bfa5f4bde029ac5b0295a6ecfaaffbfffede4bbaf4061d9455b70", size = 278742, upload-time = "2025-09-01T22:09:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3e/7d7ac6fd085023312421e0d69dfabdfb28e116e513fadbe9afe710c01893/regex-2025.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:f46d525934871ea772930e997d577d48c6983e50f206ff7b66d4ac5f8941e993", size = 271860, upload-time = "2025-09-01T22:09:42.413Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322, upload-time = "2025-09-04T09:28:11.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412, upload-time = "2025-09-04T09:28:10.587Z" }, +] + +[[package]] +name = "rignore" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/46/05a94dc55ac03cf931d18e43b86ecee5ee054cb88b7853fffd741e35009c/rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab", size = 11633, upload-time = "2025-07-19T19:24:46.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6c/e5af4383cdd7829ef9aa63ac82a6507983e02dbc7c2e7b9aa64b7b8e2c7a/rignore-0.6.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:74720d074b79f32449d5d212ce732e0144a294a184246d1f1e7bcc1fc5c83b69", size = 885885, upload-time = "2025-07-19T19:23:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/89/3e/1b02a868830e464769aa417ee195ac352fe71ff818df8ce50c4b998edb9c/rignore-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a8184fcf567bd6b6d7b85a0c138d98dd40f63054141c96b175844414c5530d7", size = 819736, upload-time = "2025-07-19T19:23:46.565Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/b9be0c523d97c09f3c6508a67ce376aba4efe41c333c58903a0d7366439a/rignore-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcb0d7d7ecc3fbccf6477bb187c04a091579ea139f15f139abe0b3b48bdfef69", size = 892779, upload-time = "2025-07-19T19:22:35.167Z" }, + { url = "https://files.pythonhosted.org/packages/91/f4/3064b06233697f2993485d132f06fe95061fef71631485da75aed246c4fd/rignore-0.6.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feac73377a156fb77b3df626c76f7e5893d9b4e9e886ac8c0f9d44f1206a2a91", size = 872116, upload-time = "2025-07-19T19:22:47.828Z" }, + { url = "https://files.pythonhosted.org/packages/99/94/cb8e7af9a3c0a665f10e2366144e0ebc66167cf846aca5f1ac31b3661598/rignore-0.6.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:465179bc30beb1f7a3439e428739a2b5777ed26660712b8c4e351b15a7c04483", size = 1163345, upload-time = "2025-07-19T19:23:00.557Z" }, + { url = "https://files.pythonhosted.org/packages/86/6b/49faa7ad85ceb6ccef265df40091d9992232d7f6055fa664fe0a8b13781c/rignore-0.6.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a4877b4dca9cf31a4d09845b300c677c86267657540d0b4d3e6d0ce3110e6e9", size = 939967, upload-time = "2025-07-19T19:23:13.494Z" }, + { url = "https://files.pythonhosted.org/packages/80/c8/b91afda10bd5ca1e3a80463340b899c0dc26a7750a9f3c94f668585c7f40/rignore-0.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456456802b1e77d1e2d149320ee32505b8183e309e228129950b807d204ddd17", size = 949717, upload-time = "2025-07-19T19:23:36.404Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f1/88bfdde58ae3fb1c1a92bb801f492eea8eafcdaf05ab9b75130023a4670b/rignore-0.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c1ff2fc223f1d9473d36923160af37bf765548578eb9d47a2f52e90da8ae408", size = 975534, upload-time = "2025-07-19T19:23:25.988Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8f/a80b4a2e48ceba56ba19e096d41263d844757e10aa36ede212571b5d8117/rignore-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e445fbc214ae18e0e644a78086ea5d0f579e210229a4fbe86367d11a4cd03c11", size = 1067837, upload-time = "2025-07-19T19:23:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/0905597af0e78748909ef58418442a480ddd93e9fc89b0ca9ab170c357c0/rignore-0.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e07d9c5270fc869bc431aadcfb6ed0447f89b8aafaa666914c077435dc76a123", size = 1134959, upload-time = "2025-07-19T19:24:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/cc/7d/0fa29adf9183b61947ce6dc8a1a9779a8ea16573f557be28ec893f6ddbaa/rignore-0.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a6ccc0ea83d2c0c6df6b166f2acacedcc220a516436490f41e99a5ae73b6019", size = 1109708, upload-time = "2025-07-19T19:24:24.176Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/92892ed86b2e36da403dd3a0187829f2d880414cef75bd612bfdf4dedebc/rignore-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:536392c5ec91755db48389546c833c4ab1426fe03e5a8522992b54ef8a244e7e", size = 1120546, upload-time = "2025-07-19T19:24:36.377Z" }, + { url = "https://files.pythonhosted.org/packages/31/1b/d29ae1fe901d523741d6d1d3ffe0d630734dd0ed6b047628a69c1e15ea44/rignore-0.6.4-cp312-cp312-win32.whl", hash = "sha256:f5f9dca46fc41c0a1e236767f68be9d63bdd2726db13a0ae3a30f68414472969", size = 642005, upload-time = "2025-07-19T19:24:56.671Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/a224944824688995374e4525115ce85fecd82442fc85edd5bcd81f4f256d/rignore-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:e02eecb9e1b9f9bf7c9030ae73308a777bed3b2486204cc74dfcfbe699ab1497", size = 720358, upload-time = "2025-07-19T19:24:49.959Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/edd7d0d5cc0720de132b6651cef95ee080ce5fca11c77d8a47db848e5f90/rignore-0.6.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b3b1e266ce45189240d14dfa1057f8013ea34b9bc8b3b44125ec8d25fdb3985", size = 885304, upload-time = "2025-07-19T19:23:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/93/a1/d8d2fb97a6548307507d049b7e93885d4a0dfa1c907af5983fd9f9362a21/rignore-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fe803628cc14714df10e8d6cdc23950a47eb9eb37dfea9a4779f4c672d2aa0", size = 818799, upload-time = "2025-07-19T19:23:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cd/949981fcc180ad5ba7b31c52e78b74b2dea6b7bf744ad4c0c4b212f6da78/rignore-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e439f034277a947a4126e2da79dbb43e33d73d7c09d3d72a927e02f8a16f59aa", size = 892024, upload-time = "2025-07-19T19:22:36.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d3/9042d701a8062d9c88f87760bbc2695ee2c23b3f002d34486b72a85f8efe/rignore-0.6.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84b5121650ae24621154c7bdba8b8970b0739d8146505c9f38e0cda9385d1004", size = 871430, upload-time = "2025-07-19T19:22:49.62Z" }, + { url = "https://files.pythonhosted.org/packages/eb/50/3370249b984212b7355f3d9241aa6d02e706067c6d194a2614dfbc0f5b27/rignore-0.6.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52b0957b585ab48a445cf8ac1dbc33a272ab060835e583b4f95aa8c67c23fb2b", size = 1160559, upload-time = "2025-07-19T19:23:01.629Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6f/2ad7f925838091d065524f30a8abda846d1813eee93328febf262b5cda21/rignore-0.6.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50359e0d5287b5e2743bd2f2fbf05df619c8282fd3af12f6628ff97b9675551d", size = 939947, upload-time = "2025-07-19T19:23:14.608Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/626ec94d62475ae7ef8b00ef98cea61cbea52a389a666703c97c4673d406/rignore-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe18096dcb1596757dfe0b412aab6d32564473ae7ee58dea0a8b4be5b1a2e3b", size = 949471, upload-time = "2025-07-19T19:23:37.521Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c3/699c4f03b3c46f4b5c02f17a0a339225da65aad547daa5b03001e7c6a382/rignore-0.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b79c212d9990a273ad91e8d9765e1766ef6ecedd3be65375d786a252762ba385", size = 974912, upload-time = "2025-07-19T19:23:27.13Z" }, + { url = "https://files.pythonhosted.org/packages/cd/35/04626c12f9f92a9fc789afc2be32838a5d9b23b6fa8b2ad4a8625638d15b/rignore-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6ffa7f2a8894c65aa5dc4e8ac8bbdf39a326c0c6589efd27686cfbb48f0197d", size = 1067281, upload-time = "2025-07-19T19:24:01.016Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9c/8f17baf3b984afea151cb9094716f6f1fb8e8737db97fc6eb6d494bd0780/rignore-0.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a63f5720dffc8d8fb0a4d02fafb8370a4031ebf3f99a4e79f334a91e905b7349", size = 1134414, upload-time = "2025-07-19T19:24:13.534Z" }, + { url = "https://files.pythonhosted.org/packages/10/88/ef84ffa916a96437c12cefcc39d474122da9626d75e3a2ebe09ec5d32f1b/rignore-0.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ce33982da47ac5dc09d19b04fa8d7c9aa6292fc0bd1ecf33076989faa8886094", size = 1109330, upload-time = "2025-07-19T19:24:25.303Z" }, + { url = "https://files.pythonhosted.org/packages/27/43/2ada5a2ec03b82e903610a1c483f516f78e47700ee6db9823f739e08b3af/rignore-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d899621867aa266824fbd9150e298f19d25b93903ef0133c09f70c65a3416eca", size = 1120381, upload-time = "2025-07-19T19:24:37.798Z" }, + { url = "https://files.pythonhosted.org/packages/3b/99/e7bcc643085131cb14dbea772def72bf1f6fe9037171ebe177c4f228abc8/rignore-0.6.4-cp313-cp313-win32.whl", hash = "sha256:d0615a6bf4890ec5a90b5fb83666822088fbd4e8fcd740c386fcce51e2f6feea", size = 641761, upload-time = "2025-07-19T19:24:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/d9/25/7798908044f27dea1a8abdc75c14523e33770137651e5f775a15143f4218/rignore-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:145177f0e32716dc2f220b07b3cde2385b994b7ea28d5c96fbec32639e9eac6f", size = 719876, upload-time = "2025-07-19T19:24:51.125Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e3/ae1e30b045bf004ad77bbd1679b9afff2be8edb166520921c6f29420516a/rignore-0.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e55bf8f9bbd186f58ab646b4a08718c77131d28a9004e477612b0cbbd5202db2", size = 891776, upload-time = "2025-07-19T19:22:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/45/a9/1193e3bc23ca0e6eb4f17cf4b99971237f97cfa6f241d98366dff90a6d09/rignore-0.6.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2521f7bf3ee1f2ab22a100a3a4eed39a97b025804e5afe4323528e9ce8f084a5", size = 871442, upload-time = "2025-07-19T19:22:50.972Z" }, + { url = "https://files.pythonhosted.org/packages/20/83/4c52ae429a0b2e1ce667e35b480e9a6846f9468c443baeaed5d775af9485/rignore-0.6.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc35773a8a9c119359ef974d0856988d4601d4daa6f532c05f66b4587cf35bc", size = 1159844, upload-time = "2025-07-19T19:23:02.751Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2f/c740f5751f464c937bfe252dc15a024ae081352cfe80d94aa16d6a617482/rignore-0.6.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b665b1ea14457d7b49e834baabc635a3b8c10cfb5cca5c21161fabdbfc2b850e", size = 939456, upload-time = "2025-07-19T19:23:15.72Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/68dbb08ac0edabf44dd144ff546a3fb0253c5af708e066847df39fc9188f/rignore-0.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c7fd339f344a8548724f289495b835bed7b81174a0bc1c28c6497854bd8855db", size = 1067070, upload-time = "2025-07-19T19:24:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3a/7e7ea6f0d31d3f5beb0f2cf2c4c362672f5f7f125714458673fc579e2bed/rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007", size = 1134598, upload-time = "2025-07-19T19:24:15.039Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/1b3307f6437d29bede5a95738aa89e6d910ba68d4054175c9f60d8e2c6b1/rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788", size = 1108862, upload-time = "2025-07-19T19:24:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/b37c82519f335f2c472a63fc6215c6f4c51063ecf3166e3acf508011afbd/rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415", size = 1120002, upload-time = "2025-07-19T19:24:38.952Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/2f05559ed5e69bdfdb56ea3982b48e6c0017c59f7241f7e1c5cae992b347/rignore-0.6.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b0e548753e55cc648f1e7b02d9f74285fe48bb49cec93643d31e563773ab3f", size = 949454, upload-time = "2025-07-19T19:23:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/0b/92/186693c8f838d670510ac1dfb35afbe964320fbffb343ba18f3d24441941/rignore-0.6.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6971ac9fdd5a0bd299a181096f091c4f3fd286643adceba98eccc03c688a6637", size = 974663, upload-time = "2025-07-19T19:23:28.24Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.18.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload-time = "2024-10-20T10:10:56.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433, upload-time = "2024-10-20T10:12:55.657Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362, upload-time = "2024-10-20T10:12:57.155Z" }, + { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118, upload-time = "2024-10-20T10:12:58.501Z" }, + { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497, upload-time = "2024-10-20T10:13:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042, upload-time = "2024-10-21T11:26:46.038Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831, upload-time = "2024-10-21T11:26:47.487Z" }, + { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692, upload-time = "2024-12-11T19:58:17.252Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777, upload-time = "2024-10-20T10:13:01.395Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523, upload-time = "2024-10-20T10:13:02.768Z" }, + { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011, upload-time = "2024-10-20T10:13:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488, upload-time = "2024-10-20T10:13:05.906Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066, upload-time = "2024-10-20T10:13:07.26Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785, upload-time = "2024-10-20T10:13:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017, upload-time = "2024-10-21T11:26:48.866Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270, upload-time = "2024-10-21T11:26:50.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059, upload-time = "2024-12-11T19:58:18.846Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583, upload-time = "2024-10-20T10:13:09.658Z" }, + { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" }, +] + +[[package]] +name = "safetensors" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/cc/738f3011628920e027a11754d9cae9abec1aed00f7ae860abbf843755233/safetensors-0.6.2.tar.gz", hash = "sha256:43ff2aa0e6fa2dc3ea5524ac7ad93a9839256b8703761e76e2d0b2a3fa4f15d9", size = 197968, upload-time = "2025-08-08T13:13:58.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b1/3f5fd73c039fc87dba3ff8b5d528bfc5a32b597fea8e7a6a4800343a17c7/safetensors-0.6.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9c85ede8ec58f120bad982ec47746981e210492a6db876882aa021446af8ffba", size = 454797, upload-time = "2025-08-08T13:13:52.066Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c9/bb114c158540ee17907ec470d01980957fdaf87b4aa07914c24eba87b9c6/safetensors-0.6.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d6675cf4b39c98dbd7d940598028f3742e0375a6b4d4277e76beb0c35f4b843b", size = 432206, upload-time = "2025-08-08T13:13:50.931Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/f70c34e47df3110e8e0bb268d90db8d4be8958a54ab0336c9be4fe86dac8/safetensors-0.6.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d2d2b3ce1e2509c68932ca03ab8f20570920cd9754b05063d4368ee52833ecd", size = 473261, upload-time = "2025-08-08T13:13:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/be9c6a7c7ef773e1996dc214e73485286df1836dbd063e8085ee1976f9cb/safetensors-0.6.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:93de35a18f46b0f5a6a1f9e26d91b442094f2df02e9fd7acf224cfec4238821a", size = 485117, upload-time = "2025-08-08T13:13:43.506Z" }, + { url = "https://files.pythonhosted.org/packages/c9/55/23f2d0a2c96ed8665bf17a30ab4ce5270413f4d74b6d87dd663258b9af31/safetensors-0.6.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89a89b505f335640f9120fac65ddeb83e40f1fd081cb8ed88b505bdccec8d0a1", size = 616154, upload-time = "2025-08-08T13:13:45.096Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/affb0bd9ce02aa46e7acddbe087912a04d953d7a4d74b708c91b5806ef3f/safetensors-0.6.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4d0d0b937e04bdf2ae6f70cd3ad51328635fe0e6214aa1fc811f3b576b3bda", size = 520713, upload-time = "2025-08-08T13:13:46.25Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5d/5a514d7b88e310c8b146e2404e0dc161282e78634d9358975fd56dfd14be/safetensors-0.6.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8045db2c872db8f4cbe3faa0495932d89c38c899c603f21e9b6486951a5ecb8f", size = 485835, upload-time = "2025-08-08T13:13:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7b/4fc3b2ba62c352b2071bea9cfbad330fadda70579f617506ae1a2f129cab/safetensors-0.6.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:81e67e8bab9878bb568cffbc5f5e655adb38d2418351dc0859ccac158f753e19", size = 521503, upload-time = "2025-08-08T13:13:47.651Z" }, + { url = "https://files.pythonhosted.org/packages/5a/50/0057e11fe1f3cead9254315a6c106a16dd4b1a19cd247f7cc6414f6b7866/safetensors-0.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0e4d029ab0a0e0e4fdf142b194514695b1d7d3735503ba700cf36d0fc7136ce", size = 652256, upload-time = "2025-08-08T13:13:53.167Z" }, + { url = "https://files.pythonhosted.org/packages/e9/29/473f789e4ac242593ac1656fbece6e1ecd860bb289e635e963667807afe3/safetensors-0.6.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fa48268185c52bfe8771e46325a1e21d317207bcabcb72e65c6e28e9ffeb29c7", size = 747281, upload-time = "2025-08-08T13:13:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/68/52/f7324aad7f2df99e05525c84d352dc217e0fa637a4f603e9f2eedfbe2c67/safetensors-0.6.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d83c20c12c2d2f465997c51b7ecb00e407e5f94d7dec3ea0cc11d86f60d3fde5", size = 692286, upload-time = "2025-08-08T13:13:55.884Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/cad1d9762868c7c5dc70c8620074df28ebb1a8e4c17d4c0cb031889c457e/safetensors-0.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d944cea65fad0ead848b6ec2c37cc0b197194bec228f8020054742190e9312ac", size = 655957, upload-time = "2025-08-08T13:13:57.029Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/e2158e17bbe57d104f0abbd95dff60dda916cf277c9f9663b4bf9bad8b6e/safetensors-0.6.2-cp38-abi3-win32.whl", hash = "sha256:cab75ca7c064d3911411461151cb69380c9225798a20e712b102edda2542ddb1", size = 308926, upload-time = "2025-08-08T13:14:01.095Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c3/c0be1135726618dc1e28d181b8c442403d8dbb9e273fd791de2d4384bcdd/safetensors-0.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:c7b214870df923cbc1593c3faee16bec59ea462758699bd3fee399d00aac072c", size = 320192, upload-time = "2025-08-08T13:13:59.467Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/84/5f4af978fff619706b8961accac84780a6d298d82a8873446f72edb4ead0/scikit_learn-1.7.1.tar.gz", hash = "sha256:24b3f1e976a4665aa74ee0fcaac2b8fccc6ae77c8e07ab25da3ba6d3292b9802", size = 7190445, upload-time = "2025-07-18T08:01:54.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/16/57f176585b35ed865f51b04117947fe20f130f78940c6477b6d66279c9c2/scikit_learn-1.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3cee419b49b5bbae8796ecd690f97aa412ef1674410c23fc3257c6b8b85b8087", size = 9260431, upload-time = "2025-07-18T08:01:22.77Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/899317092f5efcab0e9bc929e3391341cec8fb0e816c4789686770024580/scikit_learn-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2fd8b8d35817b0d9ebf0b576f7d5ffbbabdb55536b0655a8aaae629d7ffd2e1f", size = 8637191, upload-time = "2025-07-18T08:01:24.731Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1b/998312db6d361ded1dd56b457ada371a8d8d77ca2195a7d18fd8a1736f21/scikit_learn-1.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:588410fa19a96a69763202f1d6b7b91d5d7a5d73be36e189bc6396bfb355bd87", size = 9486346, upload-time = "2025-07-18T08:01:26.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/09/a2aa0b4e644e5c4ede7006748f24e72863ba2ae71897fecfd832afea01b4/scikit_learn-1.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3142f0abe1ad1d1c31a2ae987621e41f6b578144a911ff4ac94781a583adad7", size = 9290988, upload-time = "2025-07-18T08:01:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/15/fa/c61a787e35f05f17fc10523f567677ec4eeee5f95aa4798dbbbcd9625617/scikit_learn-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ddd9092c1bd469acab337d87930067c87eac6bd544f8d5027430983f1e1ae88", size = 8735568, upload-time = "2025-07-18T08:01:30.936Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/e0533303f318a0f37b88300d21f79b6ac067188d4824f1047a37214ab718/scikit_learn-1.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b7839687fa46d02e01035ad775982f2470be2668e13ddd151f0f55a5bf123bae", size = 9213143, upload-time = "2025-07-18T08:01:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/71/f3/f1df377d1bdfc3e3e2adc9c119c238b182293e6740df4cbeac6de2cc3e23/scikit_learn-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a10f276639195a96c86aa572ee0698ad64ee939a7b042060b98bd1930c261d10", size = 8591977, upload-time = "2025-07-18T08:01:34.967Z" }, + { url = "https://files.pythonhosted.org/packages/99/72/c86a4cd867816350fe8dee13f30222340b9cd6b96173955819a5561810c5/scikit_learn-1.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13679981fdaebc10cc4c13c43344416a86fcbc61449cb3e6517e1df9d12c8309", size = 9436142, upload-time = "2025-07-18T08:01:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/277967b29bd297538dc7a6ecfb1a7dce751beabd0d7f7a2233be7a4f7832/scikit_learn-1.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f1262883c6a63f067a980a8cdd2d2e7f2513dddcef6a9eaada6416a7a7cbe43", size = 9282996, upload-time = "2025-07-18T08:01:39.721Z" }, + { url = "https://files.pythonhosted.org/packages/e2/47/9291cfa1db1dae9880420d1e07dbc7e8dd4a7cdbc42eaba22512e6bde958/scikit_learn-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca6d31fb10e04d50bfd2b50d66744729dbb512d4efd0223b864e2fdbfc4cee11", size = 8707418, upload-time = "2025-07-18T08:01:42.124Z" }, + { url = "https://files.pythonhosted.org/packages/61/95/45726819beccdaa34d3362ea9b2ff9f2b5d3b8bf721bd632675870308ceb/scikit_learn-1.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:781674d096303cfe3d351ae6963ff7c958db61cde3421cd490e3a5a58f2a94ae", size = 9561466, upload-time = "2025-07-18T08:01:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1c/6f4b3344805de783d20a51eb24d4c9ad4b11a7f75c1801e6ec6d777361fd/scikit_learn-1.7.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:10679f7f125fe7ecd5fad37dd1aa2daae7e3ad8df7f3eefa08901b8254b3e12c", size = 9040467, upload-time = "2025-07-18T08:01:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/6f/80/abe18fe471af9f1d181904203d62697998b27d9b62124cd281d740ded2f9/scikit_learn-1.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f812729e38c8cb37f760dce71a9b83ccfb04f59b3dca7c6079dcdc60544fa9e", size = 9532052, upload-time = "2025-07-18T08:01:48.676Z" }, + { url = "https://files.pythonhosted.org/packages/14/82/b21aa1e0c4cee7e74864d3a5a721ab8fcae5ca55033cb6263dca297ed35b/scikit_learn-1.7.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88e1a20131cf741b84b89567e1717f27a2ced228e0f29103426102bc2e3b8ef7", size = 9361575, upload-time = "2025-07-18T08:01:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/f4777fcd5627dc6695fa6b92179d0edb7a3ac1b91bcd9a1c7f64fa7ade23/scikit_learn-1.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b1bd1d919210b6a10b7554b717c9000b5485aa95a1d0f177ae0d7ee8ec750da5", size = 9277310, upload-time = "2025-07-18T08:01:52.547Z" }, +] + +[[package]] +name = "scipy" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/4a/b927028464795439faec8eaf0b03b011005c487bb2d07409f28bf30879c4/scipy-1.16.1.tar.gz", hash = "sha256:44c76f9e8b6e8e488a586190ab38016e4ed2f8a038af7cd3defa903c0a2238b3", size = 30580861, upload-time = "2025-07-27T16:33:30.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d9/ec4864f5896232133f51382b54a08de91a9d1af7a76dfa372894026dfee2/scipy-1.16.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81b433bbeaf35728dad619afc002db9b189e45eebe2cd676effe1fb93fef2b9c", size = 36575194, upload-time = "2025-07-27T16:27:41.321Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6d/40e81ecfb688e9d25d34a847dca361982a6addf8e31f0957b1a54fbfa994/scipy-1.16.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:886cc81fdb4c6903a3bb0464047c25a6d1016fef77bb97949817d0c0d79f9e04", size = 28594590, upload-time = "2025-07-27T16:27:49.204Z" }, + { url = "https://files.pythonhosted.org/packages/0e/37/9f65178edfcc629377ce9a64fc09baebea18c80a9e57ae09a52edf84880b/scipy-1.16.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:15240c3aac087a522b4eaedb09f0ad061753c5eebf1ea430859e5bf8640d5919", size = 20866458, upload-time = "2025-07-27T16:27:54.98Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7b/749a66766871ea4cb1d1ea10f27004db63023074c22abed51f22f09770e0/scipy-1.16.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f81a25805f3659b48126b5053d9e823d3215e4a63730b5e1671852a1705921", size = 23539318, upload-time = "2025-07-27T16:28:01.604Z" }, + { url = "https://files.pythonhosted.org/packages/c4/db/8d4afec60eb833a666434d4541a3151eedbf2494ea6d4d468cbe877f00cd/scipy-1.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c62eea7f607f122069b9bad3f99489ddca1a5173bef8a0c75555d7488b6f725", size = 33292899, upload-time = "2025-07-27T16:28:09.147Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/79023ca3bbb13a015d7d2757ecca3b81293c663694c35d6541b4dca53e98/scipy-1.16.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f965bbf3235b01c776115ab18f092a95aa74c271a52577bcb0563e85738fd618", size = 35162637, upload-time = "2025-07-27T16:28:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/b6/49/0648665f9c29fdaca4c679182eb972935b3b4f5ace41d323c32352f29816/scipy-1.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f006e323874ffd0b0b816d8c6a8e7f9a73d55ab3b8c3f72b752b226d0e3ac83d", size = 35490507, upload-time = "2025-07-27T16:28:25.705Z" }, + { url = "https://files.pythonhosted.org/packages/62/8f/66cbb9d6bbb18d8c658f774904f42a92078707a7c71e5347e8bf2f52bb89/scipy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8fd15fc5085ab4cca74cb91fe0a4263b1f32e4420761ddae531ad60934c2119", size = 37923998, upload-time = "2025-07-27T16:28:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/14/c3/61f273ae550fbf1667675701112e380881905e28448c080b23b5a181df7c/scipy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7b8013c6c066609577d910d1a2a077021727af07b6fab0ee22c2f901f22352a", size = 38508060, upload-time = "2025-07-27T16:28:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/93/0b/b5c99382b839854a71ca9482c684e3472badc62620287cbbdab499b75ce6/scipy-1.16.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5451606823a5e73dfa621a89948096c6528e2896e40b39248295d3a0138d594f", size = 36533717, upload-time = "2025-07-27T16:28:51.706Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e5/69ab2771062c91e23e07c12e7d5033a6b9b80b0903ee709c3c36b3eb520c/scipy-1.16.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:89728678c5ca5abd610aee148c199ac1afb16e19844401ca97d43dc548a354eb", size = 28570009, upload-time = "2025-07-27T16:28:57.017Z" }, + { url = "https://files.pythonhosted.org/packages/f4/69/bd75dbfdd3cf524f4d753484d723594aed62cfaac510123e91a6686d520b/scipy-1.16.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e756d688cb03fd07de0fffad475649b03cb89bee696c98ce508b17c11a03f95c", size = 20841942, upload-time = "2025-07-27T16:29:01.152Z" }, + { url = "https://files.pythonhosted.org/packages/ea/74/add181c87663f178ba7d6144b370243a87af8476664d5435e57d599e6874/scipy-1.16.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5aa2687b9935da3ed89c5dbed5234576589dd28d0bf7cd237501ccfbdf1ad608", size = 23498507, upload-time = "2025-07-27T16:29:05.202Z" }, + { url = "https://files.pythonhosted.org/packages/1d/74/ece2e582a0d9550cee33e2e416cc96737dce423a994d12bbe59716f47ff1/scipy-1.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0851f6a1e537fe9399f35986897e395a1aa61c574b178c0d456be5b1a0f5ca1f", size = 33286040, upload-time = "2025-07-27T16:29:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/e4/82/08e4076df538fb56caa1d489588d880ec7c52d8273a606bb54d660528f7c/scipy-1.16.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fedc2cbd1baed37474b1924c331b97bdff611d762c196fac1a9b71e67b813b1b", size = 35176096, upload-time = "2025-07-27T16:29:17.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/79/cd710aab8c921375711a8321c6be696e705a120e3011a643efbbcdeeabcc/scipy-1.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ef500e72f9623a6735769e4b93e9dcb158d40752cdbb077f305487e3e2d1f45", size = 35490328, upload-time = "2025-07-27T16:29:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/71/73/e9cc3d35ee4526d784520d4494a3e1ca969b071fb5ae5910c036a375ceec/scipy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:978d8311674b05a8f7ff2ea6c6bce5d8b45a0cb09d4c5793e0318f448613ea65", size = 37939921, upload-time = "2025-07-27T16:29:29.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/12/c0efd2941f01940119b5305c375ae5c0fcb7ec193f806bd8f158b73a1782/scipy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:81929ed0fa7a5713fcdd8b2e6f73697d3b4c4816d090dd34ff937c20fa90e8ab", size = 38479462, upload-time = "2025-07-27T16:30:24.078Z" }, + { url = "https://files.pythonhosted.org/packages/7a/19/c3d08b675260046a991040e1ea5d65f91f40c7df1045fffff412dcfc6765/scipy-1.16.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:bcc12db731858abda693cecdb3bdc9e6d4bd200213f49d224fe22df82687bdd6", size = 36938832, upload-time = "2025-07-27T16:29:35.057Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/ce53db652c033a414a5b34598dba6b95f3d38153a2417c5a3883da429029/scipy-1.16.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:744d977daa4becb9fc59135e75c069f8d301a87d64f88f1e602a9ecf51e77b27", size = 29093084, upload-time = "2025-07-27T16:29:40.201Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/7a10ff04a7dc15f9057d05b33737ade244e4bd195caa3f7cc04d77b9e214/scipy-1.16.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:dc54f76ac18073bcecffb98d93f03ed6b81a92ef91b5d3b135dcc81d55a724c7", size = 21365098, upload-time = "2025-07-27T16:29:44.295Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/029ff710959932ad3c2a98721b20b405f05f752f07344622fd61a47c5197/scipy-1.16.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:367d567ee9fc1e9e2047d31f39d9d6a7a04e0710c86e701e053f237d14a9b4f6", size = 23896858, upload-time = "2025-07-27T16:29:48.784Z" }, + { url = "https://files.pythonhosted.org/packages/71/13/d1ef77b6bd7898720e1f0b6b3743cb945f6c3cafa7718eaac8841035ab60/scipy-1.16.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4cf5785e44e19dcd32a0e4807555e1e9a9b8d475c6afff3d21c3c543a6aa84f4", size = 33438311, upload-time = "2025-07-27T16:29:54.164Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e0/e64a6821ffbb00b4c5b05169f1c1fddb4800e9307efe3db3788995a82a2c/scipy-1.16.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3d0b80fb26d3e13a794c71d4b837e2a589d839fd574a6bbb4ee1288c213ad4a3", size = 35279542, upload-time = "2025-07-27T16:30:00.249Z" }, + { url = "https://files.pythonhosted.org/packages/57/59/0dc3c8b43e118f1e4ee2b798dcc96ac21bb20014e5f1f7a8e85cc0653bdb/scipy-1.16.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8503517c44c18d1030d666cb70aaac1cc8913608816e06742498833b128488b7", size = 35667665, upload-time = "2025-07-27T16:30:05.916Z" }, + { url = "https://files.pythonhosted.org/packages/45/5f/844ee26e34e2f3f9f8febb9343748e72daeaec64fe0c70e9bf1ff84ec955/scipy-1.16.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:30cc4bb81c41831ecfd6dc450baf48ffd80ef5aed0f5cf3ea775740e80f16ecc", size = 38045210, upload-time = "2025-07-27T16:30:11.655Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d7/210f2b45290f444f1de64bc7353aa598ece9f0e90c384b4a156f9b1a5063/scipy-1.16.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c24fa02f7ed23ae514460a22c57eca8f530dbfa50b1cfdbf4f37c05b5309cc39", size = 38593661, upload-time = "2025-07-27T16:30:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/81/ea/84d481a5237ed223bd3d32d6e82d7a6a96e34756492666c260cef16011d1/scipy-1.16.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:796a5a9ad36fa3a782375db8f4241ab02a091308eb079746bc0f874c9b998318", size = 36525921, upload-time = "2025-07-27T16:30:30.081Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9f/d9edbdeff9f3a664807ae3aea383e10afaa247e8e6255e6d2aa4515e8863/scipy-1.16.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:3ea0733a2ff73fd6fdc5fecca54ee9b459f4d74f00b99aced7d9a3adb43fb1cc", size = 28564152, upload-time = "2025-07-27T16:30:35.336Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/8125bcb1fe04bc267d103e76516243e8d5e11229e6b306bda1024a5423d1/scipy-1.16.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:85764fb15a2ad994e708258bb4ed8290d1305c62a4e1ef07c414356a24fcfbf8", size = 20836028, upload-time = "2025-07-27T16:30:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/77/9c/bf92e215701fc70bbcd3d14d86337cf56a9b912a804b9c776a269524a9e9/scipy-1.16.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ca66d980469cb623b1759bdd6e9fd97d4e33a9fad5b33771ced24d0cb24df67e", size = 23489666, upload-time = "2025-07-27T16:30:43.663Z" }, + { url = "https://files.pythonhosted.org/packages/5e/00/5e941d397d9adac41b02839011594620d54d99488d1be5be755c00cde9ee/scipy-1.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7cc1ffcc230f568549fc56670bcf3df1884c30bd652c5da8138199c8c76dae0", size = 33358318, upload-time = "2025-07-27T16:30:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/8db3aa10dde6e3e8e7eb0133f24baa011377d543f5b19c71469cf2648026/scipy-1.16.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ddfb1e8d0b540cb4ee9c53fc3dea3186f97711248fb94b4142a1b27178d8b4b", size = 35185724, upload-time = "2025-07-27T16:30:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/89/b4/6ab9ae443216807622bcff02690262d8184078ea467efee2f8c93288a3b1/scipy-1.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4dc0e7be79e95d8ba3435d193e0d8ce372f47f774cffd882f88ea4e1e1ddc731", size = 35554335, upload-time = "2025-07-27T16:30:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9a/d0e9dc03c5269a1afb60661118296a32ed5d2c24298af61b676c11e05e56/scipy-1.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f23634f9e5adb51b2a77766dac217063e764337fbc816aa8ad9aaebcd4397fd3", size = 37960310, upload-time = "2025-07-27T16:31:06.151Z" }, + { url = "https://files.pythonhosted.org/packages/5e/00/c8f3130a50521a7977874817ca89e0599b1b4ee8e938bad8ae798a0e1f0d/scipy-1.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:57d75524cb1c5a374958a2eae3d84e1929bb971204cc9d52213fb8589183fc19", size = 39319239, upload-time = "2025-07-27T16:31:59.942Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f2/1ca3eda54c3a7e4c92f6acef7db7b3a057deb135540d23aa6343ef8ad333/scipy-1.16.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:d8da7c3dd67bcd93f15618938f43ed0995982eb38973023d46d4646c4283ad65", size = 36939460, upload-time = "2025-07-27T16:31:11.865Z" }, + { url = "https://files.pythonhosted.org/packages/80/30/98c2840b293a132400c0940bb9e140171dcb8189588619048f42b2ce7b4f/scipy-1.16.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:cc1d2f2fd48ba1e0620554fe5bc44d3e8f5d4185c8c109c7fbdf5af2792cfad2", size = 29093322, upload-time = "2025-07-27T16:31:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e6/1e6e006e850622cf2a039b62d1a6ddc4497d4851e58b68008526f04a9a00/scipy-1.16.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:21a611ced9275cb861bacadbada0b8c0623bc00b05b09eb97f23b370fc2ae56d", size = 21365329, upload-time = "2025-07-27T16:31:21.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/02/72a5aa5b820589dda9a25e329ca752842bfbbaf635e36bc7065a9b42216e/scipy-1.16.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dfbb25dffc4c3dd9371d8ab456ca81beeaf6f9e1c2119f179392f0dc1ab7695", size = 23897544, upload-time = "2025-07-27T16:31:25.408Z" }, + { url = "https://files.pythonhosted.org/packages/2b/dc/7122d806a6f9eb8a33532982234bed91f90272e990f414f2830cfe656e0b/scipy-1.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0ebb7204f063fad87fc0a0e4ff4a2ff40b2a226e4ba1b7e34bf4b79bf97cd86", size = 33442112, upload-time = "2025-07-27T16:31:30.62Z" }, + { url = "https://files.pythonhosted.org/packages/24/39/e383af23564daa1021a5b3afbe0d8d6a68ec639b943661841f44ac92de85/scipy-1.16.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1b9e5962656f2734c2b285a8745358ecb4e4efbadd00208c80a389227ec61ff", size = 35286594, upload-time = "2025-07-27T16:31:36.112Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/1a0b0aff40c3056d955f38b0df5d178350c3d74734ec54f9c68d23910be5/scipy-1.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e1a106f8c023d57a2a903e771228bf5c5b27b5d692088f457acacd3b54511e4", size = 35665080, upload-time = "2025-07-27T16:31:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/64/df/ce88803e9ed6e27fe9b9abefa157cf2c80e4fa527cf17ee14be41f790ad4/scipy-1.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:709559a1db68a9abc3b2c8672c4badf1614f3b440b3ab326d86a5c0491eafae3", size = 38050306, upload-time = "2025-07-27T16:31:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/a76329897a7cae4937d403e623aa6aaea616a0bb5b36588f0b9d1c9a3739/scipy-1.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c0c804d60492a0aad7f5b2bb1862f4548b990049e27e828391ff2bf6f7199998", size = 39427705, upload-time = "2025-07-27T16:31:53.96Z" }, +] + +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + +[[package]] +name = "sentencepiece" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/15/2e7a025fc62d764b151ae6d0f2a92f8081755ebe8d4a64099accc6f77ba6/sentencepiece-0.2.1.tar.gz", hash = "sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad", size = 3228515, upload-time = "2025-08-12T07:00:51.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/be/32ce495aa1d0e0c323dcb1ba87096037358edee539cac5baf8755a6bd396/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133", size = 1943152, upload-time = "2025-08-12T06:59:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/88/7e/ff23008899a58678e98c6ff592bf4d368eee5a71af96d0df6b38a039dd4f/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6", size = 1325651, upload-time = "2025-08-12T06:59:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" }, + { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4a/85fbe1706d4d04a7e826b53f327c4b80f849cf1c7b7c5e31a20a97d8f28b/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706", size = 1943150, upload-time = "2025-08-12T06:59:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/4cfb393e287509fc2155480b9d184706ef8d9fa8cbf5505d02a5792bf220/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062", size = 1325651, upload-time = "2025-08-12T06:59:55.073Z" }, + { url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" }, + { url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" }, + { url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" }, + { url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/89eb8b2052f720a612478baf11c8227dcf1dc28cd4ea4c0c19506b5af2a2/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5d0350b686c320068702116276cfb26c066dc7e65cfef173980b11bb4d606719", size = 1943147, upload-time = "2025-08-12T07:00:21.809Z" }, + { url = "https://files.pythonhosted.org/packages/82/0b/a1432bc87f97c2ace36386ca23e8bd3b91fb40581b5e6148d24b24186419/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7f54a31cde6fa5cb030370566f68152a742f433f8d2be458463d06c208aef33", size = 1325624, upload-time = "2025-08-12T07:00:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/bbe054ebb5a5039457c590e0a4156ed073fb0fe9ce4f7523404dd5b37463/sentencepiece-0.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c83b85ab2d6576607f31df77ff86f28182be4a8de6d175d2c33ca609925f5da1", size = 1253670, upload-time = "2025-08-12T07:00:24.69Z" }, + { url = "https://files.pythonhosted.org/packages/19/ad/d5c7075f701bd97971d7c2ac2904f227566f51ef0838dfbdfdccb58cd212/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1855f57db07b51fb51ed6c9c452f570624d2b169b36f0f79ef71a6e6c618cd8b", size = 1316247, upload-time = "2025-08-12T07:00:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/fb/03/35fbe5f3d9a7435eebd0b473e09584bd3cc354ce118b960445b060d33781/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01e6912125cb45d3792f530a4d38f8e21bf884d6b4d4ade1b2de5cf7a8d2a52b", size = 1387894, upload-time = "2025-08-12T07:00:28.339Z" }, + { url = "https://files.pythonhosted.org/packages/dc/aa/956ef729aafb6c8f9c443104c9636489093bb5c61d6b90fc27aa1a865574/sentencepiece-0.2.1-cp314-cp314-win32.whl", hash = "sha256:c415c9de1447e0a74ae3fdb2e52f967cb544113a3a5ce3a194df185cbc1f962f", size = 1096698, upload-time = "2025-08-12T07:00:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/fe400d8836952cc535c81a0ce47dc6875160e5fedb71d2d9ff0e9894c2a6/sentencepiece-0.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:881b2e44b14fc19feade3cbed314be37de639fc415375cefaa5bc81a4be137fd", size = 1155115, upload-time = "2025-08-12T07:00:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/047921cf70f36c7b6b6390876b2399b3633ab73b8d0cb857e5a964238941/sentencepiece-0.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2005242a16d2dc3ac5fe18aa7667549134d37854823df4c4db244752453b78a8", size = 1133890, upload-time = "2025-08-12T07:00:34.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/11/5b414b9fae6255b5fb1e22e2ed3dc3a72d3a694e5703910e640ac78346bb/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a19adcec27c524cb7069a1c741060add95f942d1cbf7ad0d104dffa0a7d28a2b", size = 1946081, upload-time = "2025-08-12T07:00:36.97Z" }, + { url = "https://files.pythonhosted.org/packages/77/eb/7a5682bb25824db8545f8e5662e7f3e32d72a508fdce086029d89695106b/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e37e4b4c4a11662b5db521def4e44d4d30ae69a1743241412a93ae40fdcab4bb", size = 1327406, upload-time = "2025-08-12T07:00:38.669Z" }, + { url = "https://files.pythonhosted.org/packages/03/b0/811dae8fb9f2784e138785d481469788f2e0d0c109c5737372454415f55f/sentencepiece-0.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:477c81505db072b3ab627e7eab972ea1025331bd3a92bacbf798df2b75ea86ec", size = 1254846, upload-time = "2025-08-12T07:00:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/ef/23/195b2e7ec85ebb6a547969f60b723c7aca5a75800ece6cc3f41da872d14e/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:010f025a544ef770bb395091d57cb94deb9652d8972e0d09f71d85d5a0816c8c", size = 1315721, upload-time = "2025-08-12T07:00:42.914Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/553dbe4178b5f23eb28e59393dddd64186178b56b81d9b8d5c3ff1c28395/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:733e59ff1794d26db706cd41fc2d7ca5f6c64a820709cb801dc0ea31780d64ab", size = 1387458, upload-time = "2025-08-12T07:00:44.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/7c/08ff0012507297a4dd74a5420fdc0eb9e3e80f4e88cab1538d7f28db303d/sentencepiece-0.2.1-cp314-cp314t-win32.whl", hash = "sha256:d3233770f78e637dc8b1fda2cd7c3b99ec77e7505041934188a4e7fe751de3b0", size = 1099765, upload-time = "2025-08-12T07:00:46.058Z" }, + { url = "https://files.pythonhosted.org/packages/91/d5/2a69e1ce15881beb9ddfc7e3f998322f5cedcd5e4d244cb74dade9441663/sentencepiece-0.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e4366c97b68218fd30ea72d70c525e6e78a6c0a88650f57ac4c43c63b234a9d", size = 1157807, upload-time = "2025-08-12T07:00:47.673Z" }, + { url = "https://files.pythonhosted.org/packages/f3/16/54f611fcfc2d1c46cbe3ec4169780b2cfa7cf63708ef2b71611136db7513/sentencepiece-0.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:105e36e75cbac1292642045458e8da677b2342dcd33df503e640f0b457cb6751", size = 1136264, upload-time = "2025-08-12T07:00:49.485Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/9a/0b2eafc31d5c7551b6bef54ca10d29adea471e0bd16bfe985a9dc4b6633e/sentry_sdk-2.37.0.tar.gz", hash = "sha256:2c661a482dd5accf3df58464f31733545745bb4d5cf8f5e46e0e1c4eed88479f", size = 346203, upload-time = "2025-09-05T11:41:43.848Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d5/f9f4a2bf5db2ca8f692c46f3821fee1f302f1b76a0e2914aee5390fca565/sentry_sdk-2.37.0-py2.py3-none-any.whl", hash = "sha256:89c1ed205d5c25926558b64a9bed8a5b4fb295b007cecc32c0ec4bf7694da2e1", size = 368304, upload-time = "2025-09-05T11:41:41.286Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "silero-vad" +version = "5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "onnxruntime" }, + { name = "torch" }, + { name = "torchaudio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/5d/b912e45d21b8b61859a552554893222d2cdebfd0f9afa7e8ba69c7a3441a/silero_vad-5.1.tar.gz", hash = "sha256:c644275ba5df06cee596cc050ba0bd1e0f5237d1abfa44d58dd4618f6e77434d", size = 3996829, upload-time = "2024-07-09T13:19:24.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/be/0fdbc72030b93d6f55107490d5d2185ddf0dbabdc921f589649d3e92ccd5/silero_vad-5.1-py3-none-any.whl", hash = "sha256:ecb50b484f538f7a962ce5cd3c07120d9db7b9d5a0c5861ccafe459856f22c8f", size = 3939986, upload-time = "2024-07-09T13:19:21.383Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soundfile" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" }, + { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" }, + { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" }, + { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, +] + +[[package]] +name = "soxr" +version = "0.5.0.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/c0/4429bf9b3be10e749149e286aa5c53775399ec62891c6b970456c6dca325/soxr-0.5.0.post1.tar.gz", hash = "sha256:7092b9f3e8a416044e1fa138c8172520757179763b85dc53aa9504f4813cff73", size = 170853, upload-time = "2024-08-31T03:43:33.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/e3/d422d279e51e6932e7b64f1170a4f61a7ee768e0f84c9233a5b62cd2c832/soxr-0.5.0.post1-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:fef509466c9c25f65eae0ce1e4b9ac9705d22c6038c914160ddaf459589c6e31", size = 199993, upload-time = "2024-08-31T03:43:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/88adaca3c52e03bcb66b63d295df2e2d35bf355d19598c6ce84b20be7fca/soxr-0.5.0.post1-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:4704ba6b13a3f1e41d12acf192878384c1c31f71ce606829c64abdf64a8d7d32", size = 156373, upload-time = "2024-08-31T03:43:18.633Z" }, + { url = "https://files.pythonhosted.org/packages/b8/38/bad15a9e615215c8219652ca554b601663ac3b7ac82a284aca53ec2ff48c/soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd052a66471a7335b22a6208601a9d0df7b46b8d087dce4ff6e13eed6a33a2a1", size = 216564, upload-time = "2024-08-31T03:43:20.789Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1a/569ea0420a0c4801c2c8dd40d8d544989522f6014d51def689125f3f2935/soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3f16810dd649ab1f433991d2a9661e9e6a116c2b4101039b53b3c3e90a094fc", size = 248455, upload-time = "2024-08-31T03:43:22.165Z" }, + { url = "https://files.pythonhosted.org/packages/bc/10/440f1ba3d4955e0dc740bbe4ce8968c254a3d644d013eb75eea729becdb8/soxr-0.5.0.post1-cp312-abi3-win_amd64.whl", hash = "sha256:b1be9fee90afb38546bdbd7bde714d1d9a8c5a45137f97478a83b65e7f3146f6", size = 164937, upload-time = "2024-08-31T03:43:23.671Z" }, +] + +[[package]] +name = "speechbrain" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "hyperpyyaml" }, + { name = "joblib" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "scipy" }, + { name = "sentencepiece" }, + { name = "torch" }, + { name = "torchaudio" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/10/87e666544a4e0cec7cbdc09f26948994831ae0f8bbc58de3bf53b68285ff/speechbrain-1.0.3.tar.gz", hash = "sha256:fcab3c6e90012cecb1eed40ea235733b550137e73da6bfa2340ba191ec714052", size = 747735, upload-time = "2025-04-07T17:17:06.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/13/e61f1085aebee17d5fc2df19fcc5177c10379be52578afbecdd615a831c9/speechbrain-1.0.3-py3-none-any.whl", hash = "sha256:9859d4c1b1fb3af3b85523c0c89f52e45a04f305622ed55f31aa32dd2fba19e9", size = 864091, upload-time = "2025-04-07T17:17:04.706Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload-time = "2025-08-11T15:51:13.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload-time = "2025-08-11T15:51:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload-time = "2025-08-11T15:52:35.088Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload-time = "2025-08-11T15:56:34.153Z" }, + { url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload-time = "2025-08-11T15:52:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload-time = "2025-08-11T15:56:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload-time = "2025-08-11T15:55:00.645Z" }, + { url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload-time = "2025-08-11T15:55:02.965Z" }, + { url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" }, + { url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" }, + { url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, +] + +[[package]] +name = "starlette" +version = "0.47.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "tensorboardx" +version = "2.6.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/c5/d4cc6e293fb837aaf9f76dd7745476aeba8ef7ef5146c3b3f9ee375fe7a5/tensorboardx-2.6.4.tar.gz", hash = "sha256:b163ccb7798b31100b9f5fa4d6bc22dad362d7065c2f24b51e50731adde86828", size = 4769801, upload-time = "2025-06-10T22:37:07.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1d/b5d63f1a6b824282b57f7b581810d20b7a28ca951f2d5b59f1eb0782c12b/tensorboardx-2.6.4-py3-none-any.whl", hash = "sha256:5970cf3a1f0a6a6e8b180ccf46f3fe832b8a25a70b86e5a237048a7c0beb18e2", size = 87201, upload-time = "2025-06-10T22:37:05.44Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/b4/c1ce3699e81977da2ace8b16d2badfd42b060e7d33d75c4ccdbf9dc920fa/tokenizers-0.22.0.tar.gz", hash = "sha256:2e33b98525be8453f355927f3cab312c36cd3e44f4d7e9e97da2fa94d0a49dcb", size = 362771, upload-time = "2025-08-29T10:25:33.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b1/18c13648edabbe66baa85fe266a478a7931ddc0cd1ba618802eb7b8d9865/tokenizers-0.22.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:eaa9620122a3fb99b943f864af95ed14c8dfc0f47afa3b404ac8c16b3f2bb484", size = 3081954, upload-time = "2025-08-29T10:25:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/c2/02/c3c454b641bd7c4f79e4464accfae9e7dfc913a777d2e561e168ae060362/tokenizers-0.22.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:71784b9ab5bf0ff3075bceeb198149d2c5e068549c0d18fe32d06ba0deb63f79", size = 2945644, upload-time = "2025-08-29T10:25:23.405Z" }, + { url = "https://files.pythonhosted.org/packages/55/02/d10185ba2fd8c2d111e124c9d92de398aee0264b35ce433f79fb8472f5d0/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec5b71f668a8076802b0241a42387d48289f25435b86b769ae1837cad4172a17", size = 3254764, upload-time = "2025-08-29T10:25:12.445Z" }, + { url = "https://files.pythonhosted.org/packages/13/89/17514bd7ef4bf5bfff58e2b131cec0f8d5cea2b1c8ffe1050a2c8de88dbb/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea8562fa7498850d02a16178105b58803ea825b50dc9094d60549a7ed63654bb", size = 3161654, upload-time = "2025-08-29T10:25:15.493Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/bac9f3a7ef6dcceec206e3857c3b61bb16c6b702ed7ae49585f5bd85c0ef/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4136e1558a9ef2e2f1de1555dcd573e1cbc4a320c1a06c4107a3d46dc8ac6e4b", size = 3511484, upload-time = "2025-08-29T10:25:20.477Z" }, + { url = "https://files.pythonhosted.org/packages/aa/27/9c9800eb6763683010a4851db4d1802d8cab9cec114c17056eccb4d4a6e0/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf5954de3962a5fd9781dc12048d24a1a6f1f5df038c6e95db328cd22964206", size = 3712829, upload-time = "2025-08-29T10:25:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/10/e3/b1726dbc1f03f757260fa21752e1921445b5bc350389a8314dd3338836db/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8337ca75d0731fc4860e6204cc24bb36a67d9736142aa06ed320943b50b1e7ed", size = 3408934, upload-time = "2025-08-29T10:25:18.76Z" }, + { url = "https://files.pythonhosted.org/packages/d4/61/aeab3402c26874b74bb67a7f2c4b569dde29b51032c5384db592e7b216f4/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a89264e26f63c449d8cded9061adea7b5de53ba2346fc7e87311f7e4117c1cc8", size = 3345585, upload-time = "2025-08-29T10:25:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d3/498b4a8a8764cce0900af1add0f176ff24f475d4413d55b760b8cdf00893/tokenizers-0.22.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:790bad50a1b59d4c21592f9c3cf5e5cf9c3c7ce7e1a23a739f13e01fb1be377a", size = 9322986, upload-time = "2025-08-29T10:25:26.607Z" }, + { url = "https://files.pythonhosted.org/packages/a2/62/92378eb1c2c565837ca3cb5f9569860d132ab9d195d7950c1ea2681dffd0/tokenizers-0.22.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:76cf6757c73a10ef10bf06fa937c0ec7393d90432f543f49adc8cab3fb6f26cb", size = 9276630, upload-time = "2025-08-29T10:25:28.349Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f0/342d80457aa1cda7654327460f69db0d69405af1e4c453f4dc6ca7c4a76e/tokenizers-0.22.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1626cb186e143720c62c6c6b5371e62bbc10af60481388c0da89bc903f37ea0c", size = 9547175, upload-time = "2025-08-29T10:25:29.989Z" }, + { url = "https://files.pythonhosted.org/packages/14/84/8aa9b4adfc4fbd09381e20a5bc6aa27040c9c09caa89988c01544e008d18/tokenizers-0.22.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da589a61cbfea18ae267723d6b029b84598dc8ca78db9951d8f5beff72d8507c", size = 9692735, upload-time = "2025-08-29T10:25:32.089Z" }, + { url = "https://files.pythonhosted.org/packages/bf/24/83ee2b1dc76bfe05c3142e7d0ccdfe69f0ad2f1ebf6c726cea7f0874c0d0/tokenizers-0.22.0-cp39-abi3-win32.whl", hash = "sha256:dbf9d6851bddae3e046fedfb166f47743c1c7bd11c640f0691dd35ef0bcad3be", size = 2471915, upload-time = "2025-08-29T10:25:36.411Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9b/0e0bf82214ee20231845b127aa4a8015936ad5a46779f30865d10e404167/tokenizers-0.22.0-cp39-abi3-win_amd64.whl", hash = "sha256:c78174859eeaee96021f248a56c801e36bfb6bd5b067f2e95aa82445ca324f00", size = 2680494, upload-time = "2025-08-29T10:25:35.14Z" }, +] + +[[package]] +name = "torch" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/0c/2fd4df0d83a495bb5e54dca4474c4ec5f9c62db185421563deeb5dabf609/torch-2.8.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e2fab4153768d433f8ed9279c8133a114a034a61e77a3a104dcdf54388838705", size = 101906089, upload-time = "2025-08-06T14:53:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/6acf48d48838fb8fe480597d98a0668c2beb02ee4755cc136de92a0a956f/torch-2.8.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2aca0939fb7e4d842561febbd4ffda67a8e958ff725c1c27e244e85e982173c", size = 887913624, upload-time = "2025-08-06T14:56:44.33Z" }, + { url = "https://files.pythonhosted.org/packages/af/8a/5c87f08e3abd825c7dfecef5a0f1d9aa5df5dd0e3fd1fa2f490a8e512402/torch-2.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:2f4ac52f0130275d7517b03a33d2493bab3693c83dcfadf4f81688ea82147d2e", size = 241326087, upload-time = "2025-08-06T14:53:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/be/66/5c9a321b325aaecb92d4d1855421e3a055abd77903b7dab6575ca07796db/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:619c2869db3ada2c0105487ba21b5008defcc472d23f8b80ed91ac4a380283b0", size = 73630478, upload-time = "2025-08-06T14:53:57.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/4e/469ced5a0603245d6a19a556e9053300033f9c5baccf43a3d25ba73e189e/torch-2.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2b2f96814e0345f5a5aed9bf9734efa913678ed19caf6dc2cddb7930672d6128", size = 101936856, upload-time = "2025-08-06T14:54:01.526Z" }, + { url = "https://files.pythonhosted.org/packages/16/82/3948e54c01b2109238357c6f86242e6ecbf0c63a1af46906772902f82057/torch-2.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:65616ca8ec6f43245e1f5f296603e33923f4c30f93d65e103d9e50c25b35150b", size = 887922844, upload-time = "2025-08-06T14:55:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/e3/54/941ea0a860f2717d86a811adf0c2cd01b3983bdd460d0803053c4e0b8649/torch-2.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:659df54119ae03e83a800addc125856effda88b016dfc54d9f65215c3975be16", size = 241330968, upload-time = "2025-08-06T14:54:45.293Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/8b7b13bba430f5e21d77708b616f767683629fc4f8037564a177d20f90ed/torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:1a62a1ec4b0498930e2543535cf70b1bef8c777713de7ceb84cd79115f553767", size = 73915128, upload-time = "2025-08-06T14:54:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/0e/8a800e093b7f7430dbaefa80075aee9158ec22e4c4fc3c1a66e4fb96cb4f/torch-2.8.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:83c13411a26fac3d101fe8035a6b0476ae606deb8688e904e796a3534c197def", size = 102020139, upload-time = "2025-08-06T14:54:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/4a/15/5e488ca0bc6162c86a33b58642bc577c84ded17c7b72d97e49b5833e2d73/torch-2.8.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8f0a9d617a66509ded240add3754e462430a6c1fc5589f86c17b433dd808f97a", size = 887990692, upload-time = "2025-08-06T14:56:18.286Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a8/6a04e4b54472fc5dba7ca2341ab219e529f3c07b6941059fbf18dccac31f/torch-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a7242b86f42be98ac674b88a4988643b9bc6145437ec8f048fea23f72feb5eca", size = 241603453, upload-time = "2025-08-06T14:55:22.945Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/650bb7f28f771af0cb791b02348db8b7f5f64f40f6829ee82aa6ce99aabe/torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:7b677e17f5a3e69fdef7eb3b9da72622f8d322692930297e4ccb52fefc6c8211", size = 73632395, upload-time = "2025-08-06T14:55:28.645Z" }, +] + +[[package]] +name = "torch-audiomentations" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "julius" }, + { name = "torch" }, + { name = "torch-pitch-shift" }, + { name = "torchaudio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/8d/2f8fd7e34c75f5ee8de4310c3bd3f22270acd44d1f809e2fe7c12fbf35f8/torch_audiomentations-0.12.0.tar.gz", hash = "sha256:b02d4c5eb86376986a53eb405cca5e34f370ea9284411237508e720c529f7888", size = 52094, upload-time = "2025-01-15T09:07:01.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/9d/1ee04f49c15d2d632f6f7102061d7c07652858e6d91b58a091531034e84f/torch_audiomentations-0.12.0-py3-none-any.whl", hash = "sha256:1b80b91d2016ccf83979622cac8f702072a79b7dcc4c2bee40f00b26433a786b", size = 48506, upload-time = "2025-01-15T09:06:59.687Z" }, +] + +[[package]] +name = "torch-pitch-shift" +version = "1.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "primepy" }, + { name = "torch" }, + { name = "torchaudio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/a6/722a832bca75d5079f6731e005b3d0c2eec7c6c6863d030620952d143d57/torch_pitch_shift-1.2.5.tar.gz", hash = "sha256:6e1c7531f08d0f407a4c55e5ff8385a41355c5c5d27ab7fa08632e51defbd0ed", size = 4725, upload-time = "2024-09-25T19:10:12.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/4c/96ac2a09efb56cc3c41fb3ce9b6f4d8c0604499f7481d4a13a7b03e21382/torch_pitch_shift-1.2.5-py3-none-any.whl", hash = "sha256:6f8500cbc13f1c98b11cde1805ce5084f82cdd195c285f34287541f168a7c6a7", size = 5005, upload-time = "2024-09-25T19:10:11.521Z" }, +] + +[[package]] +name = "torchaudio" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "torch" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/cc/c2e2a3eb6ee956f73c68541e439916f8146170ea9cc61e72adea5c995312/torchaudio-2.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ddef94bf181e6447cbb05f38beaca8f6c5bb8d2b9ddced1aa3452025b9fc70d3", size = 1856736, upload-time = "2025-08-06T14:58:36.3Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/24dad878784f1edd62862f27173781669f0c71eb46368636787d1e364188/torchaudio-2.8.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:862e2e40bf09d865e5df080a84c1a39bbcef40e43140f4b1737eb3a389d3b38f", size = 1692930, upload-time = "2025-08-06T14:58:41.312Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a6/84d80f34472503e9eb82245d7df501c59602d75d7360e717fb9b84f91c5e/torchaudio-2.8.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:93a8583f280fe83ba021aa713319381ea71362cc87b67ee38e97a43cb2254aee", size = 4014607, upload-time = "2025-08-06T14:58:47.234Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/96ad33afa320738a7cfb4b51ba97e2f3cfb1e04ae3115d5057655103ba4f/torchaudio-2.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:4b82cacd1b8ccd543b1149d8cab257a40dfda8119023d2e3a96c66349c84bffb", size = 2499890, upload-time = "2025-08-06T14:58:55.066Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ea/2a68259c4dbb5fe44ebfdcfa40b115010d8c677221a7ef0f5577f3c4f5f1/torchaudio-2.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f851d32e94ca05e470f0c60e25726ec1e0eb71cb2ca5a0206b7fd03272ccc3c8", size = 1857045, upload-time = "2025-08-06T14:58:51.984Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/1c79a8ef29fe403b83bdfc033db852bc2a888b80c406325e5c6fb37a7f2d/torchaudio-2.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:09535a9b727c0793cd07c1ace99f3f353626281bcc3e30c2f2314e3ebc9d3f96", size = 1692755, upload-time = "2025-08-06T14:58:50.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/df/61941198e9ac6bcebfdd57e1836e4f3c23409308e3d8d7458f0198a6a366/torchaudio-2.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d2a85b124494736241884372fe1c6dd8c15e9bc1931bd325838c5c00238c7378", size = 4013897, upload-time = "2025-08-06T14:59:01.66Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ab/7175d35a4bbc4a465a9f1388571842f16eb6dec5069d7ea9c8c2d7b5b401/torchaudio-2.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c1b5139c840367a7855a062a06688a416619f6fd2ca46d9b9299b49a7d133dfd", size = 2500085, upload-time = "2025-08-06T14:58:44.95Z" }, + { url = "https://files.pythonhosted.org/packages/34/1a/69b9f8349d9d57953d5e7e445075cbf74000173fb5f5d5d9e9d59415fc63/torchaudio-2.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:68df9c9068984edff8065c2b6656725e6114fe89281b0cf122c7505305fc98a4", size = 1935600, upload-time = "2025-08-06T14:58:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/40fec21b65bccfdc5c8cdb9d511033ab07a7ad4b05f0a5b07f85c68279fc/torchaudio-2.8.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:1951f10ed092f2dda57634f6a3950ef21c9d9352551aa84a9fccd51bbda18095", size = 1704199, upload-time = "2025-08-06T14:58:43.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/53/95c3363413c2f2009f805144160b093a385f641224465fbcd717449c71fb/torchaudio-2.8.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4f7d97494698d98854129349b12061e8c3398d33bd84c929fa9aed5fd1389f73", size = 4020596, upload-time = "2025-08-06T14:59:03.031Z" }, + { url = "https://files.pythonhosted.org/packages/52/27/7fc2d7435af044ffbe0b9b8e98d99eac096d43f128a5cde23c04825d5dcf/torchaudio-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d4a715d09ac28c920d031ee1e60ecbc91e8a5079ad8c61c0277e658436c821a6", size = 2549553, upload-time = "2025-08-06T14:59:00.019Z" }, +] + +[[package]] +name = "torchmetrics" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightning-utilities" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679, upload-time = "2025-09-03T14:00:54.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161, upload-time = "2025-09-03T14:00:51.921Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "transformers" +version = "4.56.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "requests" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/21/dc88ef3da1e49af07ed69386a11047a31dcf1aaf4ded3bc4b173fbf94116/transformers-4.56.1.tar.gz", hash = "sha256:0d88b1089a563996fc5f2c34502f10516cad3ea1aa89f179f522b54c8311fe74", size = 9855473, upload-time = "2025-09-04T20:47:13.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/7c/283c3dd35e00e22a7803a0b2a65251347b745474a82399be058bde1c9f15/transformers-4.56.1-py3-none-any.whl", hash = "sha256:1697af6addfb6ddbce9618b763f4b52d5a756f6da4899ffd1b4febf58b779248", size = 11608197, upload-time = "2025-09-04T20:47:04.895Z" }, +] + +[[package]] +name = "triton" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/66/b1eb52839f563623d185f0927eb3530ee4d5ffe9d377cdaf5346b306689e/triton-3.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c1d84a5c0ec2c0f8e8a072d7fd150cab84a9c239eaddc6706c081bfae4eb04", size = 155560068, upload-time = "2025-07-30T19:58:37.081Z" }, + { url = "https://files.pythonhosted.org/packages/30/7b/0a685684ed5322d2af0bddefed7906674f67974aa88b0fae6e82e3b766f6/triton-3.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00be2964616f4c619193cb0d1b29a99bd4b001d7dc333816073f92cf2a8ccdeb", size = 155569223, upload-time = "2025-07-30T19:58:44.017Z" }, + { url = "https://files.pythonhosted.org/packages/20/63/8cb444ad5cdb25d999b7d647abac25af0ee37d292afc009940c05b82dda0/triton-3.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7936b18a3499ed62059414d7df563e6c163c5e16c3773678a3ee3d417865035d", size = 155659780, upload-time = "2025-07-30T19:58:51.171Z" }, +] + +[[package]] +name = "typer" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/82/f4bfed3bc18c6ebd6f828320811bbe4098f92a31adf4040bee59c4ae02ea/typer-0.17.3.tar.gz", hash = "sha256:0c600503d472bcf98d29914d4dcd67f80c24cc245395e2e00ba3603c9332e8ba", size = 103517, upload-time = "2025-08-30T12:35:24.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/e8/b3d537470e8404659a6335e7af868e90657efb73916ef31ddf3d8b9cb237/typer-0.17.3-py3-none-any.whl", hash = "sha256:643919a79182ab7ac7581056d93c6a2b865b026adf2872c4d02c72758e6f095b", size = 46494, upload-time = "2025-08-30T12:35:22.391Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { 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.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" }, + { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" }, + { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" }, + { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" }, + { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" }, + { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" }, + { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" }, + { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, + { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, + { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, + { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, + { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, + { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, + { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, + { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, + { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, + { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, + { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" }, + { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" }, + { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" }, + { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" }, + { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "yarl" +version = "1.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" }, + { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" }, + { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" }, + { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" }, + { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" }, + { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" }, + { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" }, + { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" }, + { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" }, + { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" }, + { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, +] diff --git a/server/README.md b/server/README.md index f91a49bf..c078f493 100644 --- a/server/README.md +++ b/server/README.md @@ -1,3 +1,29 @@ +## API Key Management + +### Finding Your User ID + +```bash +# Get your OAuth sub (user ID) - requires authentication +curl -H "Authorization: Bearer " http://localhost:1250/v1/me +# Returns: {"sub": "your-oauth-sub-here", "email": "...", ...} +``` + +### Creating API Keys + +```bash +curl -X POST http://localhost:1250/v1/user/api-keys \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "My API Key"}' +``` + +### Using API Keys + +```bash +# Use X-API-Key header instead of Authorization +curl -H "X-API-Key: " http://localhost:1250/v1/transcripts +``` + ## AWS S3/SQS usage clarification Whereby.com uploads recordings directly to our S3 bucket when meetings end. diff --git a/server/docs/data_retention.md b/server/docs/data_retention.md new file mode 100644 index 00000000..1a21b59d --- /dev/null +++ b/server/docs/data_retention.md @@ -0,0 +1,95 @@ +# Data Retention and Cleanup + +## Overview + +For public instances of Reflector, a data retention policy is automatically enforced to delete anonymous user data after a configurable period (default: 7 days). This ensures compliance with privacy expectations and prevents unbounded storage growth. + +## Configuration + +### Environment Variables + +- `PUBLIC_MODE` (bool): Must be set to `true` to enable automatic cleanup +- `PUBLIC_DATA_RETENTION_DAYS` (int): Number of days to retain anonymous data (default: 7) + +### What Gets Deleted + +When data reaches the retention period, the following items are automatically removed: + +1. **Transcripts** from anonymous users (where `user_id` is NULL): + - Database records + - Local files (audio.wav, audio.mp3, audio.json waveform) + - Storage files (cloud storage if configured) + +## Automatic Cleanup + +### Celery Beat Schedule + +When `PUBLIC_MODE=true`, a Celery beat task runs daily at 3 AM to clean up old data: + +```python +# Automatically scheduled when PUBLIC_MODE=true +"cleanup_old_public_data": { + "task": "reflector.worker.cleanup.cleanup_old_public_data", + "schedule": crontab(hour=3, minute=0), # Daily at 3 AM +} +``` + +### Running the Worker + +Ensure both Celery worker and beat scheduler are running: + +```bash +# Start Celery worker +uv run celery -A reflector.worker.app worker --loglevel=info + +# Start Celery beat scheduler (in another terminal) +uv run celery -A reflector.worker.app beat +``` + +## Manual Cleanup + +For testing or manual intervention, use the cleanup tool: + +```bash +# Delete data older than 7 days (default) +uv run python -m reflector.tools.cleanup_old_data + +# Delete data older than 30 days +uv run python -m reflector.tools.cleanup_old_data --days 30 +``` + +Note: The manual tool uses the same implementation as the Celery worker task to ensure consistency. + +## Important Notes + +1. **User Data Deletion**: Only anonymous data (where `user_id` is NULL) is deleted. Authenticated user data is preserved. + +2. **Storage Cleanup**: The system properly cleans up both local files and cloud storage when configured. + +3. **Error Handling**: If individual deletions fail, the cleanup continues and logs errors. Failed deletions are reported in the task output. + +4. **Public Instance Only**: The automatic cleanup task only runs when `PUBLIC_MODE=true` to prevent accidental data loss in private deployments. + +## Testing + +Run the cleanup tests: + +```bash +uv run pytest tests/test_cleanup.py -v +``` + +## Monitoring + +Check Celery logs for cleanup task execution: + +```bash +# Look for cleanup task logs +grep "cleanup_old_public_data" celery.log +grep "Starting cleanup of old public data" celery.log +``` + +Task statistics are logged after each run: +- Number of transcripts deleted +- Number of meetings deleted +- Number of orphaned recordings deleted +- Any errors encountered diff --git a/server/docs/gpu/api-transcription.md b/server/docs/gpu/api-transcription.md new file mode 100644 index 00000000..70b542cc --- /dev/null +++ b/server/docs/gpu/api-transcription.md @@ -0,0 +1,194 @@ +## Reflector GPU Transcription API (Specification) + +This document defines the Reflector GPU transcription API that all implementations must adhere to. Current implementations include NVIDIA Parakeet (NeMo) and Whisper (faster-whisper), both deployed on Modal.com. The API surface and response shapes are OpenAI/Whisper-compatible, so clients can switch implementations by changing only the base URL. + +### Base URL and Authentication + +- Example base URLs (Modal web endpoints): + + - Parakeet: `https://--reflector-transcriber-parakeet-web.modal.run` + - Whisper: `https://--reflector-transcriber-web.modal.run` + +- All endpoints are served under `/v1` and require a Bearer token: + +``` +Authorization: Bearer +``` + +Note: To switch implementations, deploy the desired variant and point `TRANSCRIPT_URL` to its base URL. The API is identical. + +### Supported file types + +`mp3, mp4, mpeg, mpga, m4a, wav, webm` + +### Models and languages + +- Parakeet (NVIDIA NeMo): default `nvidia/parakeet-tdt-0.6b-v2` + - Language support: only `en`. Other languages return HTTP 400. +- Whisper (faster-whisper): default `large-v2` (or deployment-specific) + - Language support: multilingual (per Whisper model capabilities). + +Note: The `model` parameter is accepted by all implementations for interface parity. Some backends may treat it as informational. + +### Endpoints + +#### POST /v1/audio/transcriptions + +Transcribe one or more uploaded audio files. + +Request: multipart/form-data + +- `file` (File) — optional. Single file to transcribe. +- `files` (File[]) — optional. One or more files to transcribe. +- `model` (string) — optional. Defaults to the implementation-specific model (see above). +- `language` (string) — optional, defaults to `en`. + - Parakeet: only `en` is accepted; other values return HTTP 400 + - Whisper: model-dependent; typically multilingual +- `batch` (boolean) — optional, defaults to `false`. + +Notes: + +- Provide either `file` or `files`, not both. If neither is provided, HTTP 400. +- `batch` requires `files`; using `batch=true` without `files` returns HTTP 400. +- Response shape for multiple files is the same regardless of `batch`. +- Files sent to this endpoint are processed in a single pass (no VAD/chunking). This is intended for short clips (roughly ≤ 30s; depends on GPU memory/model). For longer audio, prefer `/v1/audio/transcriptions-from-url` which supports VAD-based chunking. + +Responses + +Single file response: + +```json +{ + "text": "transcribed text", + "words": [ + { "word": "hello", "start": 0.0, "end": 0.5 }, + { "word": "world", "start": 0.5, "end": 1.0 } + ], + "filename": "audio.mp3" +} +``` + +Multiple files response: + +```json +{ + "results": [ + {"filename": "a1.mp3", "text": "...", "words": [...]}, + {"filename": "a2.mp3", "text": "...", "words": [...]}] +} +``` + +Notes: + +- Word objects always include keys: `word`, `start`, `end`. +- Some implementations may include a trailing space in `word` to match Whisper tokenization behavior; clients should trim if needed. + +Example curl (single file): + +```bash +curl -X POST \ + -H "Authorization: Bearer $REFLECTOR_GPU_APIKEY" \ + -F "file=@/path/to/audio.mp3" \ + -F "language=en" \ + "$BASE_URL/v1/audio/transcriptions" +``` + +Example curl (multiple files, batch): + +```bash +curl -X POST \ + -H "Authorization: Bearer $REFLECTOR_GPU_APIKEY" \ + -F "files=@/path/a1.mp3" -F "files=@/path/a2.mp3" \ + -F "batch=true" -F "language=en" \ + "$BASE_URL/v1/audio/transcriptions" +``` + +#### POST /v1/audio/transcriptions-from-url + +Transcribe a single remote audio file by URL. + +Request: application/json + +Body parameters: + +- `audio_file_url` (string) — required. URL of the audio file to transcribe. +- `model` (string) — optional. Defaults to the implementation-specific model (see above). +- `language` (string) — optional, defaults to `en`. Parakeet only accepts `en`. +- `timestamp_offset` (number) — optional, defaults to `0.0`. Added to each word's `start`/`end` in the response. + +```json +{ + "audio_file_url": "https://example.com/audio.mp3", + "model": "nvidia/parakeet-tdt-0.6b-v2", + "language": "en", + "timestamp_offset": 0.0 +} +``` + +Response: + +```json +{ + "text": "transcribed text", + "words": [ + { "word": "hello", "start": 10.0, "end": 10.5 }, + { "word": "world", "start": 10.5, "end": 11.0 } + ] +} +``` + +Notes: + +- `timestamp_offset` is added to each word’s `start`/`end` in the response. +- Implementations may perform VAD-based chunking and batching for long-form audio; word timings are adjusted accordingly. + +Example curl: + +```bash +curl -X POST \ + -H "Authorization: Bearer $REFLECTOR_GPU_APIKEY" \ + -H "Content-Type: application/json" \ + -d '{ + "audio_file_url": "https://example.com/audio.mp3", + "language": "en", + "timestamp_offset": 0 + }' \ + "$BASE_URL/v1/audio/transcriptions-from-url" +``` + +### Error handling + +- 400 Bad Request + - Parakeet: `language` other than `en` + - Missing required parameters (`file`/`files` for upload; `audio_file_url` for URL endpoint) + - Unsupported file extension +- 401 Unauthorized + - Missing or invalid Bearer token +- 404 Not Found + - `audio_file_url` does not exist + +### Implementation details + +- GPUs: A10G for small-file/live, L40S for large-file URL transcription (subject to deployment) +- VAD chunking and segment batching; word timings adjusted and overlapping ends constrained +- Pads very short segments (< 0.5s) to avoid model crashes on some backends + +### Server configuration (Reflector API) + +Set the Reflector server to use the Modal backend and point `TRANSCRIPT_URL` to your chosen deployment: + +``` +TRANSCRIPT_BACKEND=modal +TRANSCRIPT_URL=https://--reflector-transcriber-parakeet-web.modal.run +TRANSCRIPT_MODAL_API_KEY= +``` + +### Conformance tests + +Use the pytest-based conformance tests to validate any new implementation (including self-hosted) against this spec: + +``` +TRANSCRIPT_URL=https:// \ +TRANSCRIPT_MODAL_API_KEY=your-api-key \ +uv run -m pytest -m model_api --no-cov server/tests/test_model_api_transcript.py +``` diff --git a/server/docs/video-platforms/README.md b/server/docs/video-platforms/README.md new file mode 100644 index 00000000..45a615c3 --- /dev/null +++ b/server/docs/video-platforms/README.md @@ -0,0 +1,234 @@ +# Reflector Architecture: Whereby + Daily.co Recording Storage + +## System Overview + +```mermaid +graph TB + subgraph "Actors" + APP[Our App
Reflector] + WHEREBY[Whereby Service
External] + DAILY[Daily.co Service
External] + end + + subgraph "AWS S3 Buckets" + TRANSCRIPT_BUCKET[Transcript Bucket
reflector-transcripts
Output: Processed MP3s] + WHEREBY_BUCKET[Whereby Bucket
reflector-whereby-recordings
Input: Raw MP4s] + DAILY_BUCKET[Daily.co Bucket
reflector-dailyco-recordings
Input: Raw WebM tracks] + end + + subgraph "AWS Infrastructure" + SQS[SQS Queue
Whereby notifications] + end + + subgraph "Database" + DB[(PostgreSQL
Recordings, Transcripts, Meetings)] + end + + APP -->|Write processed| TRANSCRIPT_BUCKET + APP -->|Read/Delete| WHEREBY_BUCKET + APP -->|Read/Delete| DAILY_BUCKET + APP -->|Poll| SQS + APP -->|Store metadata| DB + + WHEREBY -->|Write recordings| WHEREBY_BUCKET + WHEREBY_BUCKET -->|S3 Event| SQS + WHEREBY -->|Participant webhooks
room.client.joined/left| APP + + DAILY -->|Write recordings| DAILY_BUCKET + DAILY -->|Recording webhook
recording.ready-to-download| APP +``` + +**Note on Webhook vs S3 Event for Recording Processing:** +- **Whereby**: Uses S3 Events → SQS for recording availability (S3 as source of truth, no race conditions) +- **Daily.co**: Uses webhooks for recording availability (more immediate, built-in reliability) +- **Both**: Use webhooks for participant tracking (real-time updates) + +## Credentials & Permissions + +```mermaid +graph LR + subgraph "Master Credentials" + MASTER[TRANSCRIPT_STORAGE_AWS_*
Access Key ID + Secret] + end + + subgraph "Whereby Upload Credentials" + WHEREBY_CREDS[AWS_WHEREBY_ACCESS_KEY_*
Access Key ID + Secret] + end + + subgraph "Daily.co Upload Role" + DAILY_ROLE[DAILY_STORAGE_AWS_ROLE_ARN
IAM Role ARN] + end + + subgraph "Our App Uses" + MASTER -->|Read/Write/Delete| TRANSCRIPT_BUCKET[Transcript Bucket] + MASTER -->|Read/Delete| WHEREBY_BUCKET[Whereby Bucket] + MASTER -->|Read/Delete| DAILY_BUCKET[Daily.co Bucket] + MASTER -->|Poll/Delete| SQS[SQS Queue] + end + + subgraph "We Give To Services" + WHEREBY_CREDS -->|Passed in API call| WHEREBY_SERVICE[Whereby Service] + WHEREBY_SERVICE -->|Write Only| WHEREBY_BUCKET + + DAILY_ROLE -->|Passed in API call| DAILY_SERVICE[Daily.co Service] + DAILY_SERVICE -->|Assume Role| DAILY_ROLE + DAILY_SERVICE -->|Write Only| DAILY_BUCKET + end +``` + +# Video Platform Recording Integration + +This document explains how Reflector receives and identifies multitrack audio recordings from different video platforms. + +## Platform Comparison + +| Platform | Delivery Method | Track Identification | +|----------|----------------|---------------------| +| **Daily.co** | Webhook | Explicit track list in payload | +| **Whereby** | SQS (S3 notifications) | Single file per notification | + +--- + +## Daily.co (Webhook-based) + +Daily.co uses **webhooks** to notify Reflector when recordings are ready. + +### How It Works + +1. **Daily.co sends webhook** when recording is ready + - Event type: `recording.ready-to-download` + - Endpoint: `/v1/daily/webhook` (`reflector/views/daily.py:46-102`) + +2. **Webhook payload explicitly includes track list**: +```json +{ + "recording_id": "7443ee0a-dab1-40eb-b316-33d6c0d5ff88", + "room_name": "daily-20251020193458", + "tracks": [ + { + "type": "audio", + "s3Key": "monadical/daily-20251020193458/1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922", + "size": 831843 + }, + { + "type": "audio", + "s3Key": "monadical/daily-20251020193458/1760988935484-a37c35e3-6f8e-4274-a482-e9d0f102a732-cam-audio-1760988943823", + "size": 408438 + }, + { + "type": "video", + "s3Key": "monadical/daily-20251020193458/...-video.webm", + "size": 30000000 + } + ] +} +``` + +3. **System extracts audio tracks** (`daily.py:211`): +```python +track_keys = [t.s3Key for t in tracks if t.type == "audio"] +``` + +4. **Triggers multitrack processing** (`daily.py:213-218`): +```python +process_multitrack_recording.delay( + bucket_name=bucket_name, # reflector-dailyco-local + room_name=room_name, # daily-20251020193458 + recording_id=recording_id, # 7443ee0a-dab1-40eb-b316-33d6c0d5ff88 + track_keys=track_keys # Only audio s3Keys +) +``` + +### Key Advantage: No Ambiguity + +Even though multiple meetings may share the same S3 bucket/folder (`monadical/`), **there's no ambiguity** because: +- Each webhook payload contains the exact `s3Key` list for that specific `recording_id` +- No need to scan folders or guess which files belong together +- Each track's s3Key includes the room timestamp subfolder (e.g., `daily-20251020193458/`) + +The room name includes timestamp (`daily-20251020193458`) to keep recordings organized, but **the webhook's explicit track list is what prevents mixing files from different meetings**. + +### Track Timeline Extraction + +Daily.co provides timing information in two places: + +**1. PyAV WebM Metadata (current approach)**: +```python +# Read from WebM container stream metadata +stream.start_time = 8.130s # Meeting-relative timing +``` + +**2. Filename Timestamps (alternative approach, commit 3bae9076)**: +``` +Filename format: {recording_start_ts}-{uuid}-cam-audio-{track_start_ts}.webm +Example: 1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922.webm + +Parse timestamps: +- recording_start_ts: 1760988935484 (Unix ms) +- track_start_ts: 1760988935922 (Unix ms) +- offset: (1760988935922 - 1760988935484) / 1000 = 0.438s +``` + +**Time Difference (PyAV vs Filename)**: +``` +Track 0: + Filename offset: 438ms + PyAV metadata: 229ms + Difference: 209ms + +Track 1: + Filename offset: 8339ms + PyAV metadata: 8130ms + Difference: 209ms +``` + +**Consistent 209ms delta** suggests network/encoding delay between file upload initiation (filename) and actual audio stream start (metadata). + +**Current implementation uses PyAV metadata** because: +- More accurate (represents when audio actually started) +- Padding BEFORE transcription produces correct Whisper timestamps automatically +- No manual offset adjustment needed during transcript merge + +### Why Re-encoding During Padding + +Padding coincidentally involves re-encoding, which is important for Daily.co + Whisper: + +**Problem:** Daily.co skips frames in recordings when microphone is muted or paused +- WebM containers have gaps where audio frames should be +- Whisper doesn't understand these gaps and produces incorrect timestamps +- Example: 5s of audio with 2s muted → file has frames only for 3s, Whisper thinks duration is 3s + +**Solution:** Re-encoding via PyAV filter graph (`adelay` + `aresample`) +- Restores missing frames as silence +- Produces continuous audio stream without gaps +- Whisper now sees correct duration and produces accurate timestamps + +**Why combined with padding:** +- Already re-encoding for padding (adding initial silence) +- More performant to do both operations in single PyAV pipeline +- Padded values needed for mixdown anyway (creating final MP3) + +Implementation: `main_multitrack_pipeline.py:_apply_audio_padding_streaming()` + +--- + +## Whereby (SQS-based) + +Whereby uses **AWS SQS** (via S3 notifications) to notify Reflector when files are uploaded. + +### How It Works + +1. **Whereby uploads recording** to S3 +2. **S3 sends notification** to SQS queue (one notification per file) +3. **Reflector polls SQS queue** (`worker/process.py:process_messages()`) +4. **System processes single file** (`worker/process.py:process_recording()`) + +### Key Difference from Daily.co + +**Whereby (SQS):** System receives S3 notification "file X was created" - only knows about one file at a time, would need to scan folder to find related files + +**Daily.co (Webhook):** Daily explicitly tells system which files belong together in the webhook payload + +--- + + diff --git a/server/docs/webhook.md b/server/docs/webhook.md new file mode 100644 index 00000000..b103d655 --- /dev/null +++ b/server/docs/webhook.md @@ -0,0 +1,233 @@ +# Reflector Webhook Documentation + +## Overview + +Reflector supports webhook notifications to notify external systems when transcript processing is completed. Webhooks can be configured per room and are triggered automatically after a transcript is successfully processed. + +## Configuration + +Webhooks are configured at the room level with two fields: +- `webhook_url`: The HTTPS endpoint to receive webhook notifications +- `webhook_secret`: Optional secret key for HMAC signature verification (auto-generated if not provided) + +## Events + +### `transcript.completed` + +Triggered when a transcript has been fully processed, including transcription, diarization, summarization, topic detection and calendar event integration. + +### `test` + +A test event that can be triggered manually to verify webhook configuration. + +## Webhook Request Format + +### Headers + +All webhook requests include the following headers: + +| Header | Description | Example | +|--------|-------------|---------| +| `Content-Type` | Always `application/json` | `application/json` | +| `User-Agent` | Identifies Reflector as the source | `Reflector-Webhook/1.0` | +| `X-Webhook-Event` | The event type | `transcript.completed` or `test` | +| `X-Webhook-Retry` | Current retry attempt number | `0`, `1`, `2`... | +| `X-Webhook-Signature` | HMAC signature (if secret configured) | `t=1735306800,v1=abc123...` | + +### Signature Verification + +If a webhook secret is configured, Reflector includes an HMAC-SHA256 signature in the `X-Webhook-Signature` header to verify the webhook authenticity. + +The signature format is: `t={timestamp},v1={signature}` + +To verify the signature: +1. Extract the timestamp and signature from the header +2. Create the signed payload: `{timestamp}.{request_body}` +3. Compute HMAC-SHA256 of the signed payload using your webhook secret +4. Compare the computed signature with the received signature + +Example verification (Python): +```python +import hmac +import hashlib + +def verify_webhook_signature(payload: bytes, signature_header: str, secret: str) -> bool: + # Parse header: "t=1735306800,v1=abc123..." + parts = dict(part.split("=") for part in signature_header.split(",")) + timestamp = parts["t"] + received_signature = parts["v1"] + + # Create signed payload + signed_payload = f"{timestamp}.{payload.decode('utf-8')}" + + # Compute expected signature + expected_signature = hmac.new( + secret.encode("utf-8"), + signed_payload.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + + # Compare signatures + return hmac.compare_digest(expected_signature, received_signature) +``` + +## Event Payloads + +### `transcript.completed` Event + +This event includes a convenient URL for accessing the transcript: +- `frontend_url`: Direct link to view the transcript in the web interface + +```json +{ + "event": "transcript.completed", + "event_id": "transcript.completed-abc-123-def-456", + "timestamp": "2025-08-27T12:34:56.789012Z", + "transcript": { + "id": "abc-123-def-456", + "room_id": "room-789", + "created_at": "2025-08-27T12:00:00Z", + "duration": 1800.5, + "title": "Q3 Product Planning Meeting", + "short_summary": "Team discussed Q3 product roadmap, prioritizing mobile app features and API improvements.", + "long_summary": "The product team met to finalize the Q3 roadmap. Key decisions included...", + "webvtt": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nWelcome everyone to today's meeting...", + "topics": [ + { + "title": "Introduction and Agenda", + "summary": "Meeting kickoff with agenda review", + "timestamp": 0.0, + "duration": 120.0, + "webvtt": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nWelcome everyone..." + }, + { + "title": "Mobile App Features Discussion", + "summary": "Team reviewed proposed mobile app features for Q3", + "timestamp": 120.0, + "duration": 600.0, + "webvtt": "WEBVTT\n\n00:02:00.000 --> 00:02:10.000\nLet's talk about the mobile app..." + } + ], + "participants": [ + { + "id": "participant-1", + "name": "John Doe", + "speaker": "Speaker 1" + }, + { + "id": "participant-2", + "name": "Jane Smith", + "speaker": "Speaker 2" + } + ], + "source_language": "en", + "target_language": "en", + "status": "completed", + "frontend_url": "https://app.reflector.com/transcripts/abc-123-def-456" + }, + "room": { + "id": "room-789", + "name": "Product Team Room" + }, + "calendar_event": { + "id": "calendar-event-123", + "ics_uid": "event-123", + "title": "Q3 Product Planning Meeting", + "start_time": "2025-08-27T12:00:00Z", + "end_time": "2025-08-27T12:30:00Z", + "description": "Team discussed Q3 product roadmap, prioritizing mobile app features and API improvements.", + "location": "Conference Room 1", + "attendees": [ + { + "id": "participant-1", + "name": "John Doe", + "speaker": "Speaker 1" + }, + { + "id": "participant-2", + "name": "Jane Smith", + "speaker": "Speaker 2" + } + ] + } +} +``` + +### `test` Event + +```json +{ + "event": "test", + "event_id": "test.2025-08-27T12:34:56.789012Z", + "timestamp": "2025-08-27T12:34:56.789012Z", + "message": "This is a test webhook from Reflector", + "room": { + "id": "room-789", + "name": "Product Team Room" + } +} +``` + +## Retry Policy + +Webhooks are delivered with automatic retry logic to handle transient failures. When a webhook delivery fails due to server errors or network issues, Reflector will automatically retry the delivery multiple times over an extended period. + +### Retry Mechanism + +Reflector implements an exponential backoff strategy for webhook retries: + +- **Initial retry delay**: 60 seconds after the first failure +- **Exponential backoff**: Each subsequent retry waits approximately twice as long as the previous one +- **Maximum retry interval**: 1 hour (backoff is capped at this duration) +- **Maximum retry attempts**: 30 attempts total +- **Total retry duration**: Retries continue for approximately 24 hours + +### How Retries Work + +When a webhook fails, Reflector will: +1. Wait 60 seconds, then retry (attempt #1) +2. If it fails again, wait ~2 minutes, then retry (attempt #2) +3. Continue doubling the wait time up to a maximum of 1 hour between attempts +4. Keep retrying at 1-hour intervals until successful or 30 attempts are exhausted + +The `X-Webhook-Retry` header indicates the current retry attempt number (0 for the initial attempt, 1 for first retry, etc.), allowing your endpoint to track retry attempts. + +### Retry Behavior by HTTP Status Code + +| Status Code | Behavior | +|-------------|----------| +| 2xx (Success) | No retry, webhook marked as delivered | +| 4xx (Client Error) | No retry, request is considered permanently failed | +| 5xx (Server Error) | Automatic retry with exponential backoff | +| Network/Timeout Error | Automatic retry with exponential backoff | + +**Important Notes:** +- Webhooks timeout after 30 seconds. If your endpoint takes longer to respond, it will be considered a timeout error and retried. +- During the retry period (~24 hours), you may receive the same webhook multiple times if your endpoint experiences intermittent failures. +- There is no mechanism to manually retry failed webhooks after the retry period expires. + +## Testing Webhooks + +You can test your webhook configuration before processing transcripts: + +```http +POST /v1/rooms/{room_id}/webhook/test +``` + +Response: +```json +{ + "success": true, + "status_code": 200, + "message": "Webhook test successful", + "response_preview": "OK" +} +``` + +Or in case of failure: +```json +{ + "success": false, + "error": "Webhook request timed out (10 seconds)" +} +``` diff --git a/server/env.example b/server/env.example index 70b4b229..7375bf0a 100644 --- a/server/env.example +++ b/server/env.example @@ -27,7 +27,7 @@ AUTH_JWT_AUDIENCE= #TRANSCRIPT_MODAL_API_KEY=xxxxx TRANSCRIPT_BACKEND=modal -TRANSCRIPT_URL=https://monadical-sas--reflector-transcriber-web.modal.run +TRANSCRIPT_URL=https://monadical-sas--reflector-transcriber-parakeet-web.modal.run TRANSCRIPT_MODAL_API_KEY= ## ======================================================= @@ -71,3 +71,30 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run ## Sentry DSN configuration #SENTRY_DSN= + +## ======================================================= +## Video Platform Configuration +## ======================================================= + +## Whereby +#WHEREBY_API_KEY=your-whereby-api-key +#WHEREBY_WEBHOOK_SECRET=your-whereby-webhook-secret +#WHEREBY_STORAGE_AWS_ACCESS_KEY_ID=your-aws-key +#WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY=your-aws-secret +#AWS_PROCESS_RECORDING_QUEUE_URL=https://sqs.us-west-2.amazonaws.com/... + +## Daily.co +#DAILY_API_KEY=your-daily-api-key +#DAILY_WEBHOOK_SECRET=your-daily-webhook-secret +#DAILY_SUBDOMAIN=your-subdomain +#DAILY_WEBHOOK_UUID= # Auto-populated by recreate_daily_webhook.py script +#DAILYCO_STORAGE_AWS_ROLE_ARN=... # IAM role ARN for Daily.co S3 access +#DAILYCO_STORAGE_AWS_BUCKET_NAME=reflector-dailyco +#DAILYCO_STORAGE_AWS_REGION=us-west-2 + +## Whereby (optional separate bucket) +#WHEREBY_STORAGE_AWS_BUCKET_NAME=reflector-whereby +#WHEREBY_STORAGE_AWS_REGION=us-east-1 + +## Platform Configuration +#DEFAULT_VIDEO_PLATFORM=whereby # Default platform for new rooms diff --git a/server/gpu/modal_deployments/reflector_transcriber.py b/server/gpu/modal_deployments/reflector_transcriber.py deleted file mode 100644 index 4bbbe512..00000000 --- a/server/gpu/modal_deployments/reflector_transcriber.py +++ /dev/null @@ -1,161 +0,0 @@ -import os -import tempfile -import threading - -import modal -from pydantic import BaseModel - -MODELS_DIR = "/models" - -MODEL_NAME = "large-v2" -MODEL_COMPUTE_TYPE: str = "float16" -MODEL_NUM_WORKERS: int = 1 - -MINUTES = 60 # seconds - -volume = modal.Volume.from_name("models", create_if_missing=True) - -app = modal.App("reflector-transcriber") - - -def download_model(): - from faster_whisper import download_model - - volume.reload() - - download_model(MODEL_NAME, cache_dir=MODELS_DIR) - - volume.commit() - - -image = ( - modal.Image.debian_slim(python_version="3.12") - .pip_install( - "huggingface_hub==0.27.1", - "hf-transfer==0.1.9", - "torch==2.5.1", - "faster-whisper==1.1.1", - ) - .env( - { - "HF_HUB_ENABLE_HF_TRANSFER": "1", - "LD_LIBRARY_PATH": ( - "/usr/local/lib/python3.12/site-packages/nvidia/cudnn/lib/:" - "/opt/conda/lib/python3.12/site-packages/nvidia/cublas/lib/" - ), - } - ) - .run_function(download_model, volumes={MODELS_DIR: volume}) -) - - -@app.cls( - gpu="A10G", - timeout=5 * MINUTES, - scaledown_window=5 * MINUTES, - allow_concurrent_inputs=6, - image=image, - volumes={MODELS_DIR: volume}, -) -class Transcriber: - @modal.enter() - def enter(self): - import faster_whisper - import torch - - self.lock = threading.Lock() - self.use_gpu = torch.cuda.is_available() - self.device = "cuda" if self.use_gpu else "cpu" - self.model = faster_whisper.WhisperModel( - MODEL_NAME, - device=self.device, - compute_type=MODEL_COMPUTE_TYPE, - num_workers=MODEL_NUM_WORKERS, - download_root=MODELS_DIR, - local_files_only=True, - ) - - @modal.method() - def transcribe_segment( - self, - audio_data: str, - audio_suffix: str, - language: str, - ): - with tempfile.NamedTemporaryFile("wb+", suffix=f".{audio_suffix}") as fp: - fp.write(audio_data) - - with self.lock: - segments, _ = self.model.transcribe( - fp.name, - language=language, - beam_size=5, - word_timestamps=True, - vad_filter=True, - vad_parameters={"min_silence_duration_ms": 500}, - ) - - segments = list(segments) - text = "".join(segment.text for segment in segments) - words = [ - {"word": word.word, "start": word.start, "end": word.end} - for segment in segments - for word in segment.words - ] - - return {"text": text, "words": words} - - -@app.function( - scaledown_window=60, - timeout=60, - allow_concurrent_inputs=40, - secrets=[ - modal.Secret.from_name("reflector-gpu"), - ], - volumes={MODELS_DIR: volume}, -) -@modal.asgi_app() -def web(): - from fastapi import Body, Depends, FastAPI, HTTPException, UploadFile, status - from fastapi.security import OAuth2PasswordBearer - from typing_extensions import Annotated - - transcriber = Transcriber() - - app = FastAPI() - - oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - - supported_file_types = ["mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"] - - def apikey_auth(apikey: str = Depends(oauth2_scheme)): - if apikey != os.environ["REFLECTOR_GPU_APIKEY"]: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid API key", - headers={"WWW-Authenticate": "Bearer"}, - ) - - class TranscriptResponse(BaseModel): - result: dict - - @app.post("/v1/audio/transcriptions", dependencies=[Depends(apikey_auth)]) - def transcribe( - file: UploadFile, - model: str = "whisper-1", - language: Annotated[str, Body(...)] = "en", - ) -> TranscriptResponse: - audio_data = file.file.read() - audio_suffix = file.filename.split(".")[-1] - assert audio_suffix in supported_file_types - - func = transcriber.transcribe_segment.spawn( - audio_data=audio_data, - audio_suffix=audio_suffix, - language=language, - ) - result = func.get() - return result - - return app diff --git a/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py b/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py new file mode 100644 index 00000000..21dc1260 --- /dev/null +++ b/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py @@ -0,0 +1,36 @@ +"""Add webhook fields to rooms + +Revision ID: 0194f65cd6d3 +Revises: 5a8907fd1d78 +Create Date: 2025-08-27 09:03:19.610995 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0194f65cd6d3" +down_revision: Union[str, None] = "5a8907fd1d78" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column(sa.Column("webhook_url", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("webhook_secret", sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_column("webhook_secret") + batch_op.drop_column("webhook_url") + + # ### end Alembic commands ### diff --git a/server/migrations/versions/0ce521cda2ee_remove_user_id_from_meeting_table.py b/server/migrations/versions/0ce521cda2ee_remove_user_id_from_meeting_table.py new file mode 100644 index 00000000..2e76e8a6 --- /dev/null +++ b/server/migrations/versions/0ce521cda2ee_remove_user_id_from_meeting_table.py @@ -0,0 +1,36 @@ +"""remove user_id from meeting table + +Revision ID: 0ce521cda2ee +Revises: 6dec9fb5b46c +Create Date: 2025-09-10 12:40:55.688899 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0ce521cda2ee" +down_revision: Union[str, None] = "6dec9fb5b46c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.drop_column("user_id") + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.add_column( + sa.Column("user_id", sa.VARCHAR(), autoincrement=False, nullable=True) + ) + + # ### end Alembic commands ### diff --git a/server/migrations/versions/1e49625677e4_add_platform_support.py b/server/migrations/versions/1e49625677e4_add_platform_support.py new file mode 100644 index 00000000..fa403f92 --- /dev/null +++ b/server/migrations/versions/1e49625677e4_add_platform_support.py @@ -0,0 +1,50 @@ +"""add_platform_support + +Revision ID: 1e49625677e4 +Revises: 9e3f7b2a4c8e +Create Date: 2025-10-08 13:17:29.943612 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1e49625677e4" +down_revision: Union[str, None] = "9e3f7b2a4c8e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add platform field with default 'whereby' for backward compatibility.""" + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "platform", + sa.String(), + nullable=True, + server_default=None, + ) + ) + + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "platform", + sa.String(), + nullable=False, + server_default="whereby", + ) + ) + + +def downgrade() -> None: + """Remove platform field.""" + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.drop_column("platform") + + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_column("platform") diff --git a/server/migrations/versions/2ae3db106d4e_clean_up_orphaned_room_id_references_in_.py b/server/migrations/versions/2ae3db106d4e_clean_up_orphaned_room_id_references_in_.py new file mode 100644 index 00000000..c091ab49 --- /dev/null +++ b/server/migrations/versions/2ae3db106d4e_clean_up_orphaned_room_id_references_in_.py @@ -0,0 +1,32 @@ +"""clean up orphaned room_id references in meeting table + +Revision ID: 2ae3db106d4e +Revises: def1b5867d4c +Create Date: 2025-09-11 10:35:15.759967 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2ae3db106d4e" +down_revision: Union[str, None] = "def1b5867d4c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Set room_id to NULL for meetings that reference non-existent rooms + op.execute(""" + UPDATE meeting + SET room_id = NULL + WHERE room_id IS NOT NULL + AND room_id NOT IN (SELECT id FROM room WHERE id IS NOT NULL) + """) + + +def downgrade() -> None: + # Cannot restore orphaned references - no operation needed + pass diff --git a/server/migrations/versions/2b92a1b03caa_add_daily_participant_session_table_.py b/server/migrations/versions/2b92a1b03caa_add_daily_participant_session_table_.py new file mode 100644 index 00000000..90c3e94e --- /dev/null +++ b/server/migrations/versions/2b92a1b03caa_add_daily_participant_session_table_.py @@ -0,0 +1,79 @@ +"""add daily participant session table with immutable left_at + +Revision ID: 2b92a1b03caa +Revises: f8294b31f022 +Create Date: 2025-11-13 20:29:30.486577 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2b92a1b03caa" +down_revision: Union[str, None] = "f8294b31f022" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create table + op.create_table( + "daily_participant_session", + sa.Column("id", sa.String(), nullable=False), + sa.Column("meeting_id", sa.String(), nullable=False), + sa.Column("room_id", sa.String(), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("user_id", sa.String(), nullable=True), + sa.Column("user_name", sa.String(), nullable=False), + sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("left_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["meeting_id"], ["meeting.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["room_id"], ["room.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("daily_participant_session", schema=None) as batch_op: + batch_op.create_index( + "idx_daily_session_meeting_left", ["meeting_id", "left_at"], unique=False + ) + batch_op.create_index("idx_daily_session_room", ["room_id"], unique=False) + + # Create trigger function to prevent left_at from being updated once set + op.execute(""" + CREATE OR REPLACE FUNCTION prevent_left_at_update() + RETURNS TRIGGER AS $$ + BEGIN + IF OLD.left_at IS NOT NULL THEN + RAISE EXCEPTION 'left_at is immutable once set'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """) + + # Create trigger + op.execute(""" + CREATE TRIGGER prevent_left_at_update_trigger + BEFORE UPDATE ON daily_participant_session + FOR EACH ROW + EXECUTE FUNCTION prevent_left_at_update(); + """) + + +def downgrade() -> None: + # Drop trigger + op.execute( + "DROP TRIGGER IF EXISTS prevent_left_at_update_trigger ON daily_participant_session;" + ) + + # Drop trigger function + op.execute("DROP FUNCTION IF EXISTS prevent_left_at_update();") + + # Drop indexes and table + with op.batch_alter_table("daily_participant_session", schema=None) as batch_op: + batch_op.drop_index("idx_daily_session_room") + batch_op.drop_index("idx_daily_session_meeting_left") + + op.drop_table("daily_participant_session") diff --git a/server/migrations/versions/5a8907fd1d78_add_cascade_delete_to_meeting_consent_.py b/server/migrations/versions/5a8907fd1d78_add_cascade_delete_to_meeting_consent_.py new file mode 100644 index 00000000..af6a5c22 --- /dev/null +++ b/server/migrations/versions/5a8907fd1d78_add_cascade_delete_to_meeting_consent_.py @@ -0,0 +1,50 @@ +"""add cascade delete to meeting consent foreign key + +Revision ID: 5a8907fd1d78 +Revises: 0ab2d7ffaa16 +Create Date: 2025-08-26 17:26:50.945491 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5a8907fd1d78" +down_revision: Union[str, None] = "0ab2d7ffaa16" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("meeting_consent", schema=None) as batch_op: + batch_op.drop_constraint( + batch_op.f("meeting_consent_meeting_id_fkey"), type_="foreignkey" + ) + batch_op.create_foreign_key( + batch_op.f("meeting_consent_meeting_id_fkey"), + "meeting", + ["meeting_id"], + ["id"], + ondelete="CASCADE", + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("meeting_consent", schema=None) as batch_op: + batch_op.drop_constraint( + batch_op.f("meeting_consent_meeting_id_fkey"), type_="foreignkey" + ) + batch_op.create_foreign_key( + batch_op.f("meeting_consent_meeting_id_fkey"), + "meeting", + ["meeting_id"], + ["id"], + ) + + # ### end Alembic commands ### diff --git a/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py b/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py new file mode 100644 index 00000000..4c6e2f7b --- /dev/null +++ b/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py @@ -0,0 +1,53 @@ +"""remove_one_active_meeting_per_room_constraint + +Revision ID: 6025e9b2bef2 +Revises: 2ae3db106d4e +Create Date: 2025-08-18 18:45:44.418392 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "6025e9b2bef2" +down_revision: Union[str, None] = "2ae3db106d4e" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Remove the unique constraint that prevents multiple active meetings per room + # This is needed to support calendar integration with overlapping meetings + # Check if index exists before trying to drop it + from alembic import context + + if context.get_context().dialect.name == "postgresql": + conn = op.get_bind() + result = conn.execute( + sa.text( + "SELECT 1 FROM pg_indexes WHERE indexname = 'idx_one_active_meeting_per_room'" + ) + ) + if result.fetchone(): + op.drop_index("idx_one_active_meeting_per_room", table_name="meeting") + else: + # For SQLite, just try to drop it + try: + op.drop_index("idx_one_active_meeting_per_room", table_name="meeting") + except: + pass + + +def downgrade() -> None: + # Restore the unique constraint + op.create_index( + "idx_one_active_meeting_per_room", + "meeting", + ["room_id"], + unique=True, + postgresql_where=sa.text("is_active = true"), + sqlite_where=sa.text("is_active = 1"), + ) diff --git a/server/migrations/versions/61882a919591_webhook_url_and_secret_null_by_default.py b/server/migrations/versions/61882a919591_webhook_url_and_secret_null_by_default.py new file mode 100644 index 00000000..d02df839 --- /dev/null +++ b/server/migrations/versions/61882a919591_webhook_url_and_secret_null_by_default.py @@ -0,0 +1,28 @@ +"""webhook url and secret null by default + + +Revision ID: 61882a919591 +Revises: 0194f65cd6d3 +Create Date: 2025-08-29 11:46:36.738091 + +""" + +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = "61882a919591" +down_revision: Union[str, None] = "0194f65cd6d3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py b/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py new file mode 100644 index 00000000..c0a29246 --- /dev/null +++ b/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py @@ -0,0 +1,35 @@ +"""make meeting room_id required and add foreign key + +Revision ID: 6dec9fb5b46c +Revises: 61882a919591 +Create Date: 2025-09-10 10:47:06.006819 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "6dec9fb5b46c" +down_revision: Union[str, None] = "61882a919591" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.create_foreign_key( + None, "room", ["room_id"], ["id"], ondelete="CASCADE" + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.drop_constraint("meeting_room_id_fkey", type_="foreignkey") + + # ### end Alembic commands ### diff --git a/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py b/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py new file mode 100644 index 00000000..ef8f881c --- /dev/null +++ b/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py @@ -0,0 +1,38 @@ +"""add user api keys + +Revision ID: 9e3f7b2a4c8e +Revises: dc035ff72fd5 +Create Date: 2025-10-17 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9e3f7b2a4c8e" +down_revision: Union[str, None] = "dc035ff72fd5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_api_key", + sa.Column("id", sa.String(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("key_hash", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + with op.batch_alter_table("user_api_key", schema=None) as batch_op: + batch_op.create_index("idx_user_api_key_hash", ["key_hash"], unique=True) + batch_op.create_index("idx_user_api_key_user_id", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_table("user_api_key") diff --git a/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py b/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py new file mode 100644 index 00000000..868e3479 --- /dev/null +++ b/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py @@ -0,0 +1,34 @@ +"""add_grace_period_fields_to_meeting + +Revision ID: d4a1c446458c +Revises: 6025e9b2bef2 +Create Date: 2025-08-18 18:50:37.768052 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d4a1c446458c" +down_revision: Union[str, None] = "6025e9b2bef2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add fields to track when participants left for grace period logic + op.add_column( + "meeting", sa.Column("last_participant_left_at", sa.DateTime(timezone=True)) + ) + op.add_column( + "meeting", + sa.Column("grace_period_minutes", sa.Integer, server_default=sa.text("15")), + ) + + +def downgrade() -> None: + op.drop_column("meeting", "grace_period_minutes") + op.drop_column("meeting", "last_participant_left_at") diff --git a/server/migrations/versions/d8e204bbf615_add_calendar.py b/server/migrations/versions/d8e204bbf615_add_calendar.py new file mode 100644 index 00000000..a134989d --- /dev/null +++ b/server/migrations/versions/d8e204bbf615_add_calendar.py @@ -0,0 +1,129 @@ +"""add calendar + +Revision ID: d8e204bbf615 +Revises: d4a1c446458c +Create Date: 2025-09-10 19:56:22.295756 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "d8e204bbf615" +down_revision: Union[str, None] = "d4a1c446458c" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "calendar_event", + sa.Column("id", sa.String(), nullable=False), + sa.Column("room_id", sa.String(), nullable=False), + sa.Column("ics_uid", sa.Text(), nullable=False), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("attendees", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("location", sa.Text(), nullable=True), + sa.Column("ics_raw_data", sa.Text(), nullable=True), + sa.Column("last_synced", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "is_deleted", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["room_id"], + ["room.id"], + name="fk_calendar_event_room_id", + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("room_id", "ics_uid", name="uq_room_calendar_event"), + ) + with op.batch_alter_table("calendar_event", schema=None) as batch_op: + batch_op.create_index( + "idx_calendar_event_deleted", + ["is_deleted"], + unique=False, + postgresql_where=sa.text("NOT is_deleted"), + ) + batch_op.create_index( + "idx_calendar_event_room_start", ["room_id", "start_time"], unique=False + ) + + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.add_column(sa.Column("calendar_event_id", sa.String(), nullable=True)) + batch_op.add_column( + sa.Column( + "calendar_metadata", + postgresql.JSONB(astext_type=sa.Text()), + nullable=True, + ) + ) + batch_op.create_index( + "idx_meeting_calendar_event", ["calendar_event_id"], unique=False + ) + batch_op.create_foreign_key( + "fk_meeting_calendar_event_id", + "calendar_event", + ["calendar_event_id"], + ["id"], + ondelete="SET NULL", + ) + + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.add_column(sa.Column("ics_url", sa.Text(), nullable=True)) + batch_op.add_column( + sa.Column( + "ics_fetch_interval", sa.Integer(), server_default="300", nullable=True + ) + ) + batch_op.add_column( + sa.Column( + "ics_enabled", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + ) + ) + batch_op.add_column( + sa.Column("ics_last_sync", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("ics_last_etag", sa.Text(), nullable=True)) + batch_op.create_index("idx_room_ics_enabled", ["ics_enabled"], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.drop_index("idx_room_ics_enabled") + batch_op.drop_column("ics_last_etag") + batch_op.drop_column("ics_last_sync") + batch_op.drop_column("ics_enabled") + batch_op.drop_column("ics_fetch_interval") + batch_op.drop_column("ics_url") + + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.drop_constraint("fk_meeting_calendar_event_id", type_="foreignkey") + batch_op.drop_index("idx_meeting_calendar_event") + batch_op.drop_column("calendar_metadata") + batch_op.drop_column("calendar_event_id") + + with op.batch_alter_table("calendar_event", schema=None) as batch_op: + batch_op.drop_index("idx_calendar_event_room_start") + batch_op.drop_index( + "idx_calendar_event_deleted", postgresql_where=sa.text("NOT is_deleted") + ) + + op.drop_table("calendar_event") + # ### end Alembic commands ### diff --git a/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py b/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py new file mode 100644 index 00000000..c38a0227 --- /dev/null +++ b/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py @@ -0,0 +1,43 @@ +"""remove_grace_period_fields + +Revision ID: dc035ff72fd5 +Revises: d8e204bbf615 +Create Date: 2025-09-11 10:36:45.197588 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "dc035ff72fd5" +down_revision: Union[str, None] = "d8e204bbf615" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Remove grace period columns from meeting table + op.drop_column("meeting", "last_participant_left_at") + op.drop_column("meeting", "grace_period_minutes") + + +def downgrade() -> None: + # Add back grace period columns to meeting table + op.add_column( + "meeting", + sa.Column( + "last_participant_left_at", sa.DateTime(timezone=True), nullable=True + ), + ) + op.add_column( + "meeting", + sa.Column( + "grace_period_minutes", + sa.Integer(), + server_default=sa.text("15"), + nullable=True, + ), + ) diff --git a/server/migrations/versions/def1b5867d4c_make_meeting_room_id_nullable_but_keep_.py b/server/migrations/versions/def1b5867d4c_make_meeting_room_id_nullable_but_keep_.py new file mode 100644 index 00000000..982bea27 --- /dev/null +++ b/server/migrations/versions/def1b5867d4c_make_meeting_room_id_nullable_but_keep_.py @@ -0,0 +1,34 @@ +"""make meeting room_id nullable but keep foreign key + +Revision ID: def1b5867d4c +Revises: 0ce521cda2ee +Create Date: 2025-09-11 09:42:18.697264 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "def1b5867d4c" +down_revision: Union[str, None] = "0ce521cda2ee" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=True) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("meeting", schema=None) as batch_op: + batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=False) + + # ### end Alembic commands ### diff --git a/server/migrations/versions/f8294b31f022_add_track_keys.py b/server/migrations/versions/f8294b31f022_add_track_keys.py new file mode 100644 index 00000000..7eda6ccc --- /dev/null +++ b/server/migrations/versions/f8294b31f022_add_track_keys.py @@ -0,0 +1,28 @@ +"""add_track_keys + +Revision ID: f8294b31f022 +Revises: 1e49625677e4 +Create Date: 2025-10-27 18:52:17.589167 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f8294b31f022" +down_revision: Union[str, None] = "1e49625677e4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("recording", schema=None) as batch_op: + batch_op.add_column(sa.Column("track_keys", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("recording", schema=None) as batch_op: + batch_op.drop_column("track_keys") diff --git a/server/pyproject.toml b/server/pyproject.toml index 47d314d9..ffa28d15 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -12,7 +12,6 @@ dependencies = [ "requests>=2.31.0", "aiortc>=1.5.0", "sortedcontainers>=2.4.0", - "loguru>=0.7.0", "pydantic-settings>=2.0.2", "structlog>=23.1.0", "uvicorn[standard]>=0.23.1", @@ -27,7 +26,6 @@ dependencies = [ "prometheus-fastapi-instrumentator>=6.1.0", "sentencepiece>=0.1.99", "protobuf>=4.24.3", - "profanityfilter>=2.0.6", "celery>=5.3.4", "redis>=5.0.1", "python-jose[cryptography]>=3.3.0", @@ -40,6 +38,7 @@ dependencies = [ "llama-index-llms-openai-like>=0.4.0", "pytest-env>=1.1.5", "webvtt-py>=0.5.0", + "icalendar>=6.0.0", ] [dependency-groups] @@ -113,13 +112,14 @@ source = ["reflector"] [tool.pytest_env] ENVIRONMENT = "pytest" DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test" +AUTH_BACKEND = "jwt" [tool.pytest.ini_options] addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v" testpaths = ["tests"] asyncio_mode = "auto" markers = [ - "gpu_modal: mark test to run only with GPU Modal endpoints (deselect with '-m \"not gpu_modal\"')", + "model_api: tests for the unified model-serving HTTP API (backend- and hardware-agnostic)", ] [tool.ruff.lint] @@ -131,7 +131,7 @@ select = [ [tool.ruff.lint.per-file-ignores] "reflector/processors/summary/summary_builder.py" = ["E501"] -"gpu/**.py" = ["PLC0415"] +"gpu/modal_deployments/**.py" = ["PLC0415"] "reflector/tools/**.py" = ["PLC0415"] "migrations/versions/**.py" = ["PLC0415"] "tests/**.py" = ["PLC0415"] diff --git a/server/reflector/app.py b/server/reflector/app.py index e1d07d20..2ca76acb 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -12,6 +12,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup from reflector.logger import logger from reflector.metrics import metrics_init from reflector.settings import settings +from reflector.views.daily import router as daily_router from reflector.views.meetings import router as meetings_router from reflector.views.rooms import router as rooms_router from reflector.views.rtc_offer import router as rtc_offer_router @@ -26,6 +27,8 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router from reflector.views.transcripts_websocket import router as transcripts_websocket_router from reflector.views.user import router as user_router +from reflector.views.user_api_keys import router as user_api_keys_router +from reflector.views.user_websocket import router as user_ws_router from reflector.views.whereby import router as whereby_router from reflector.views.zulip import router as zulip_router @@ -65,6 +68,12 @@ app.add_middleware( allow_headers=["*"], ) + +@app.get("/health") +async def health(): + return {"status": "healthy"} + + # metrics instrumentator = Instrumentator( excluded_handlers=["/docs", "/metrics"], @@ -84,8 +93,11 @@ app.include_router(transcripts_websocket_router, prefix="/v1") app.include_router(transcripts_webrtc_router, prefix="/v1") app.include_router(transcripts_process_router, prefix="/v1") app.include_router(user_router, prefix="/v1") +app.include_router(user_api_keys_router, prefix="/v1") +app.include_router(user_ws_router, prefix="/v1") app.include_router(zulip_router, prefix="/v1") app.include_router(whereby_router, prefix="/v1") +app.include_router(daily_router, prefix="/v1/daily") add_pagination(app) # prepare celery diff --git a/server/reflector/asynctask.py b/server/reflector/asynctask.py new file mode 100644 index 00000000..61523a6f --- /dev/null +++ b/server/reflector/asynctask.py @@ -0,0 +1,27 @@ +import asyncio +import functools + +from reflector.db import get_database + + +def asynctask(f): + @functools.wraps(f) + def wrapper(*args, **kwargs): + async def run_with_db(): + database = get_database() + await database.connect() + try: + return await f(*args, **kwargs) + finally: + await database.disconnect() + + coro = run_with_db() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop and loop.is_running(): + return loop.run_until_complete(coro) + return asyncio.run(coro) + + return wrapper diff --git a/server/reflector/auth/auth_jwt.py b/server/reflector/auth/auth_jwt.py index 4cc8ba03..0dcff9a0 100644 --- a/server/reflector/auth/auth_jwt.py +++ b/server/reflector/auth/auth_jwt.py @@ -1,14 +1,16 @@ -from typing import Annotated, Optional +from typing import Annotated, List, Optional from fastapi import Depends, HTTPException -from fastapi.security import OAuth2PasswordBearer +from fastapi.security import APIKeyHeader, OAuth2PasswordBearer from jose import JWTError, jwt from pydantic import BaseModel +from reflector.db.user_api_keys import user_api_keys_controller from reflector.logger import logger from reflector.settings import settings oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read() jwt_algorithm = settings.AUTH_JWT_ALGORITHM @@ -26,7 +28,7 @@ class JWTException(Exception): class UserInfo(BaseModel): sub: str - email: str + email: Optional[str] = None def __getitem__(self, key): return getattr(self, key) @@ -58,33 +60,53 @@ def authenticated(token: Annotated[str, Depends(oauth2_scheme)]): return None -def current_user( - token: Annotated[Optional[str], Depends(oauth2_scheme)], - jwtauth: JWTAuth = Depends(), -): - if token is None: - raise HTTPException(status_code=401, detail="Not authenticated") - try: - payload = jwtauth.verify_token(token) - sub = payload["sub"] - return UserInfo(sub=sub) - except JWTError as e: - logger.error(f"JWT error: {e}") - raise HTTPException(status_code=401, detail="Invalid authentication") +async def _authenticate_user( + jwt_token: Optional[str], + api_key: Optional[str], + jwtauth: JWTAuth, +) -> UserInfo | None: + user_infos: List[UserInfo] = [] + if api_key: + user_api_key = await user_api_keys_controller.verify_key(api_key) + if user_api_key: + user_infos.append(UserInfo(sub=user_api_key.user_id, email=None)) + if jwt_token: + try: + payload = jwtauth.verify_token(jwt_token) + sub = payload["sub"] + email = payload["email"] + user_infos.append(UserInfo(sub=sub, email=email)) + except JWTError as e: + logger.error(f"JWT error: {e}") + raise HTTPException(status_code=401, detail="Invalid authentication") -def current_user_optional( - token: Annotated[Optional[str], Depends(oauth2_scheme)], - jwtauth: JWTAuth = Depends(), -): - # we accept no token, but if one is provided, it must be a valid one. - if token is None: + if len(user_infos) == 0: return None - try: - payload = jwtauth.verify_token(token) - sub = payload["sub"] - email = payload["email"] - return UserInfo(sub=sub, email=email) - except JWTError as e: - logger.error(f"JWT error: {e}") - raise HTTPException(status_code=401, detail="Invalid authentication") + + if len(set([x.sub for x in user_infos])) > 1: + raise JWTException( + status_code=401, + detail="Invalid authentication: more than one user provided", + ) + + return user_infos[0] + + +async def current_user( + jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)], + api_key: Annotated[Optional[str], Depends(api_key_header)], + jwtauth: JWTAuth = Depends(), +): + user = await _authenticate_user(jwt_token, api_key, jwtauth) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +async def current_user_optional( + jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)], + api_key: Annotated[Optional[str], Depends(api_key_header)], + jwtauth: JWTAuth = Depends(), +): + return await _authenticate_user(jwt_token, api_key, jwtauth) diff --git a/server/reflector/dailyco_api/README.md b/server/reflector/dailyco_api/README.md new file mode 100644 index 00000000..88ec2cc3 --- /dev/null +++ b/server/reflector/dailyco_api/README.md @@ -0,0 +1,6 @@ +anything about Daily.co api interaction + +- webhook event shapes +- REST api client + +No REST api client existing found in the wild; the official lib is about working with videocall as a bot \ No newline at end of file diff --git a/server/reflector/dailyco_api/__init__.py b/server/reflector/dailyco_api/__init__.py new file mode 100644 index 00000000..1a65478b --- /dev/null +++ b/server/reflector/dailyco_api/__init__.py @@ -0,0 +1,96 @@ +""" +Daily.co API Module +""" + +# Client +from .client import DailyApiClient, DailyApiError + +# Request models +from .requests import ( + CreateMeetingTokenRequest, + CreateRoomRequest, + CreateWebhookRequest, + MeetingTokenProperties, + RecordingsBucketConfig, + RoomProperties, + UpdateWebhookRequest, +) + +# Response models +from .responses import ( + MeetingParticipant, + MeetingParticipantsResponse, + MeetingResponse, + MeetingTokenResponse, + RecordingResponse, + RecordingS3Info, + RoomPresenceParticipant, + RoomPresenceResponse, + RoomResponse, + WebhookResponse, +) + +# Webhook utilities +from .webhook_utils import ( + extract_room_name, + parse_participant_joined, + parse_participant_left, + parse_recording_error, + parse_recording_ready, + parse_recording_started, + parse_webhook_payload, + verify_webhook_signature, +) + +# Webhook models +from .webhooks import ( + DailyTrack, + DailyWebhookEvent, + ParticipantJoinedPayload, + ParticipantLeftPayload, + RecordingErrorPayload, + RecordingReadyToDownloadPayload, + RecordingStartedPayload, +) + +__all__ = [ + # Client + "DailyApiClient", + "DailyApiError", + # Requests + "CreateRoomRequest", + "RoomProperties", + "RecordingsBucketConfig", + "CreateMeetingTokenRequest", + "MeetingTokenProperties", + "CreateWebhookRequest", + "UpdateWebhookRequest", + # Responses + "RoomResponse", + "RoomPresenceResponse", + "RoomPresenceParticipant", + "MeetingParticipantsResponse", + "MeetingParticipant", + "MeetingResponse", + "RecordingResponse", + "RecordingS3Info", + "MeetingTokenResponse", + "WebhookResponse", + # Webhooks + "DailyWebhookEvent", + "DailyTrack", + "ParticipantJoinedPayload", + "ParticipantLeftPayload", + "RecordingStartedPayload", + "RecordingReadyToDownloadPayload", + "RecordingErrorPayload", + # Webhook utilities + "verify_webhook_signature", + "extract_room_name", + "parse_webhook_payload", + "parse_participant_joined", + "parse_participant_left", + "parse_recording_started", + "parse_recording_ready", + "parse_recording_error", +] diff --git a/server/reflector/dailyco_api/client.py b/server/reflector/dailyco_api/client.py new file mode 100644 index 00000000..24221bb2 --- /dev/null +++ b/server/reflector/dailyco_api/client.py @@ -0,0 +1,527 @@ +""" +Daily.co API Client + +Complete async client for Daily.co REST API with Pydantic models. + +Reference: https://docs.daily.co/reference/rest-api +""" + +from http import HTTPStatus +from typing import Any + +import httpx +import structlog + +from reflector.utils.string import NonEmptyString + +from .requests import ( + CreateMeetingTokenRequest, + CreateRoomRequest, + CreateWebhookRequest, + UpdateWebhookRequest, +) +from .responses import ( + MeetingParticipantsResponse, + MeetingResponse, + MeetingTokenResponse, + RecordingResponse, + RoomPresenceResponse, + RoomResponse, + WebhookResponse, +) + +logger = structlog.get_logger(__name__) + + +class DailyApiError(Exception): + """Daily.co API error with full request/response context.""" + + def __init__(self, operation: str, response: httpx.Response): + self.operation = operation + self.response = response + self.status_code = response.status_code + self.response_body = response.text + self.url = str(response.url) + self.request_body = ( + response.request.content.decode() if response.request.content else None + ) + + super().__init__( + f"Daily.co API error: {operation} failed with status {self.status_code}" + ) + + +class DailyApiClient: + """ + Complete async client for Daily.co REST API. + + Usage: + # Direct usage + client = DailyApiClient(api_key="your_api_key") + room = await client.create_room(CreateRoomRequest(name="my-room")) + await client.close() # Clean up when done + + # Context manager (recommended) + async with DailyApiClient(api_key="your_api_key") as client: + room = await client.create_room(CreateRoomRequest(name="my-room")) + """ + + BASE_URL = "https://api.daily.co/v1" + DEFAULT_TIMEOUT = 10.0 + + def __init__( + self, + api_key: NonEmptyString, + webhook_secret: NonEmptyString | None = None, + timeout: float = DEFAULT_TIMEOUT, + base_url: NonEmptyString | None = None, + ): + """ + Initialize Daily.co API client. + + Args: + api_key: Daily.co API key (Bearer token) + webhook_secret: Base64-encoded HMAC secret for webhook verification. + Must match the 'hmac' value provided when creating webhooks. + Generate with: base64.b64encode(os.urandom(32)).decode() + timeout: Default request timeout in seconds + base_url: Override base URL (for testing) + """ + self.api_key = api_key + self.webhook_secret = webhook_secret + self.timeout = timeout + self.base_url = base_url or self.BASE_URL + + self.headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + self._client: httpx.AsyncClient | None = None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient(timeout=self.timeout) + return self._client + + async def close(self): + if self._client is not None: + await self._client.aclose() + self._client = None + + async def _handle_response( + self, response: httpx.Response, operation: str + ) -> dict[str, Any]: + """ + Handle API response with error logging. + + Args: + response: HTTP response + operation: Operation name for logging (e.g., "create_room") + + Returns: + Parsed JSON response + + Raises: + DailyApiError: If request failed with full context + """ + if response.status_code >= 400: + logger.error( + f"Daily.co API error: {operation}", + status_code=response.status_code, + response_body=response.text, + request_body=response.request.content.decode() + if response.request.content + else None, + url=str(response.url), + ) + raise DailyApiError(operation, response) + + return response.json() + + # ============================================================================ + # ROOMS + # ============================================================================ + + async def create_room(self, request: CreateRoomRequest) -> RoomResponse: + """ + Create a new Daily.co room. + + Reference: https://docs.daily.co/reference/rest-api/rooms/create-room + + Args: + request: Room creation request with name, privacy, and properties + + Returns: + Created room data including URL and ID + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.post( + f"{self.base_url}/rooms", + headers=self.headers, + json=request.model_dump(exclude_none=True), + ) + + data = await self._handle_response(response, "create_room") + return RoomResponse(**data) + + async def get_room(self, room_name: NonEmptyString) -> RoomResponse: + """ + Get room configuration. + + Args: + room_name: Daily.co room name + + Returns: + Room configuration data + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.get( + f"{self.base_url}/rooms/{room_name}", + headers=self.headers, + ) + + data = await self._handle_response(response, "get_room") + return RoomResponse(**data) + + async def get_room_presence( + self, room_name: NonEmptyString + ) -> RoomPresenceResponse: + """ + Get current participants in a room (real-time presence). + + Reference: https://docs.daily.co/reference/rest-api/rooms/get-room-presence + + Args: + room_name: Daily.co room name + + Returns: + List of currently present participants with join time and duration + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.get( + f"{self.base_url}/rooms/{room_name}/presence", + headers=self.headers, + ) + + data = await self._handle_response(response, "get_room_presence") + return RoomPresenceResponse(**data) + + async def delete_room(self, room_name: NonEmptyString) -> None: + """ + Delete a room (idempotent - succeeds even if room doesn't exist). + + Reference: https://docs.daily.co/reference/rest-api/rooms/delete-room + + Args: + room_name: Daily.co room name + + Raises: + httpx.HTTPStatusError: If API request fails (except 404) + """ + client = await self._get_client() + response = await client.delete( + f"{self.base_url}/rooms/{room_name}", + headers=self.headers, + ) + + # Idempotent delete - 404 means already deleted + if response.status_code == HTTPStatus.NOT_FOUND: + logger.debug("Room not found (already deleted)", room_name=room_name) + return + + await self._handle_response(response, "delete_room") + + # ============================================================================ + # MEETINGS + # ============================================================================ + + async def get_meeting(self, meeting_id: NonEmptyString) -> MeetingResponse: + """ + Get full meeting information including participants. + + Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-information + + Args: + meeting_id: Daily.co meeting/session ID + + Returns: + Meeting metadata including room, duration, participants, and status + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.get( + f"{self.base_url}/meetings/{meeting_id}", + headers=self.headers, + ) + + data = await self._handle_response(response, "get_meeting") + return MeetingResponse(**data) + + async def get_meeting_participants( + self, + meeting_id: NonEmptyString, + limit: int | None = None, + joined_after: NonEmptyString | None = None, + joined_before: NonEmptyString | None = None, + ) -> MeetingParticipantsResponse: + """ + Get historical participant data from a completed meeting (paginated). + + Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants + + Args: + meeting_id: Daily.co meeting/session ID + limit: Maximum number of participant records to return + joined_after: Return participants who joined after this participant_id + joined_before: Return participants who joined before this participant_id + + Returns: + List of participants with join times and duration + + Raises: + httpx.HTTPStatusError: If API request fails (404 when no more participants) + + Note: + For pagination, use joined_after with the last participant_id from previous response. + Returns 404 when no more participants remain. + """ + params = {} + if limit is not None: + params["limit"] = limit + if joined_after is not None: + params["joined_after"] = joined_after + if joined_before is not None: + params["joined_before"] = joined_before + + client = await self._get_client() + response = await client.get( + f"{self.base_url}/meetings/{meeting_id}/participants", + headers=self.headers, + params=params, + ) + + data = await self._handle_response(response, "get_meeting_participants") + return MeetingParticipantsResponse(**data) + + # ============================================================================ + # RECORDINGS + # ============================================================================ + + async def get_recording(self, recording_id: NonEmptyString) -> RecordingResponse: + """ + Get recording metadata and status. + + Reference: https://docs.daily.co/reference/rest-api/recordings + + Args: + recording_id: Daily.co recording ID + + Returns: + Recording metadata including status, duration, and S3 info + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.get( + f"{self.base_url}/recordings/{recording_id}", + headers=self.headers, + ) + + data = await self._handle_response(response, "get_recording") + return RecordingResponse(**data) + + # ============================================================================ + # MEETING TOKENS + # ============================================================================ + + async def create_meeting_token( + self, request: CreateMeetingTokenRequest + ) -> MeetingTokenResponse: + """ + Create a meeting token for participant authentication. + + Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token + + Args: + request: Token properties including room name, user_id, permissions + + Returns: + JWT meeting token + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.post( + f"{self.base_url}/meeting-tokens", + headers=self.headers, + json=request.model_dump(exclude_none=True), + ) + + data = await self._handle_response(response, "create_meeting_token") + return MeetingTokenResponse(**data) + + # ============================================================================ + # WEBHOOKS + # ============================================================================ + + async def list_webhooks(self) -> list[WebhookResponse]: + """ + List all configured webhooks for this account. + + Reference: https://docs.daily.co/reference/rest-api/webhooks + + Returns: + List of webhook configurations + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.get( + f"{self.base_url}/webhooks", + headers=self.headers, + ) + + data = await self._handle_response(response, "list_webhooks") + + # Daily.co returns array directly (not paginated) + if isinstance(data, list): + return [WebhookResponse(**wh) for wh in data] + + # Future-proof: handle potential pagination envelope + if isinstance(data, dict) and "data" in data: + return [WebhookResponse(**wh) for wh in data["data"]] + + logger.warning("Unexpected webhook list response format", data=data) + return [] + + async def create_webhook(self, request: CreateWebhookRequest) -> WebhookResponse: + """ + Create a new webhook subscription. + + Reference: https://docs.daily.co/reference/rest-api/webhooks + + Args: + request: Webhook configuration with URL, event types, and HMAC secret + + Returns: + Created webhook with UUID and state + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.post( + f"{self.base_url}/webhooks", + headers=self.headers, + json=request.model_dump(exclude_none=True), + ) + + data = await self._handle_response(response, "create_webhook") + return WebhookResponse(**data) + + async def update_webhook( + self, webhook_uuid: NonEmptyString, request: UpdateWebhookRequest + ) -> WebhookResponse: + """ + Update webhook configuration. + + Note: Daily.co may not support PATCH for all fields. + Common pattern is delete + recreate. + + Reference: https://docs.daily.co/reference/rest-api/webhooks + + Args: + webhook_uuid: Webhook UUID to update + request: Updated webhook configuration + + Returns: + Updated webhook configuration + + Raises: + httpx.HTTPStatusError: If API request fails + """ + client = await self._get_client() + response = await client.patch( + f"{self.base_url}/webhooks/{webhook_uuid}", + headers=self.headers, + json=request.model_dump(exclude_none=True), + ) + + data = await self._handle_response(response, "update_webhook") + return WebhookResponse(**data) + + async def delete_webhook(self, webhook_uuid: NonEmptyString) -> None: + """ + Delete a webhook. + + Reference: https://docs.daily.co/reference/rest-api/webhooks + + Args: + webhook_uuid: Webhook UUID to delete + + Raises: + httpx.HTTPStatusError: If webhook not found or deletion fails + """ + client = await self._get_client() + response = await client.delete( + f"{self.base_url}/webhooks/{webhook_uuid}", + headers=self.headers, + ) + + await self._handle_response(response, "delete_webhook") + + # ============================================================================ + # HELPER METHODS + # ============================================================================ + + async def find_webhook_by_url(self, url: NonEmptyString) -> WebhookResponse | None: + """ + Find a webhook by its URL. + + Args: + url: Webhook endpoint URL to search for + + Returns: + Webhook if found, None otherwise + """ + webhooks = await self.list_webhooks() + for webhook in webhooks: + if webhook.url == url: + return webhook + return None + + async def find_webhooks_by_pattern( + self, pattern: NonEmptyString + ) -> list[WebhookResponse]: + """ + Find webhooks matching a URL pattern (e.g., 'ngrok'). + + Args: + pattern: String to match in webhook URLs + + Returns: + List of matching webhooks + """ + webhooks = await self.list_webhooks() + return [wh for wh in webhooks if pattern in wh.url] diff --git a/server/reflector/dailyco_api/requests.py b/server/reflector/dailyco_api/requests.py new file mode 100644 index 00000000..e943b90f --- /dev/null +++ b/server/reflector/dailyco_api/requests.py @@ -0,0 +1,158 @@ +""" +Daily.co API Request Models + +Reference: https://docs.daily.co/reference/rest-api +""" + +from typing import List, Literal + +from pydantic import BaseModel, Field + +from reflector.utils.string import NonEmptyString + + +class RecordingsBucketConfig(BaseModel): + """ + S3 bucket configuration for raw-tracks recordings. + + Reference: https://docs.daily.co/reference/rest-api/rooms/create-room + """ + + bucket_name: NonEmptyString = Field(description="S3 bucket name") + bucket_region: NonEmptyString = Field(description="AWS region (e.g., 'us-east-1')") + assume_role_arn: NonEmptyString = Field( + description="AWS IAM role ARN that Daily.co will assume to write recordings" + ) + allow_api_access: bool = Field( + default=True, + description="Whether to allow API access to recording metadata", + ) + + +class RoomProperties(BaseModel): + """ + Room configuration properties. + """ + + enable_recording: Literal["cloud", "local", "raw-tracks"] | None = Field( + default=None, + description="Recording mode: 'cloud' for mixed, 'local' for local recording, 'raw-tracks' for multitrack, None to disable", + ) + enable_chat: bool = Field(default=True, description="Enable in-meeting chat") + enable_screenshare: bool = Field(default=True, description="Enable screen sharing") + start_video_off: bool = Field( + default=False, description="Start with video off for all participants" + ) + start_audio_off: bool = Field( + default=False, description="Start with audio muted for all participants" + ) + exp: int | None = Field( + None, description="Room expiration timestamp (Unix epoch seconds)" + ) + recordings_bucket: RecordingsBucketConfig | None = Field( + None, description="S3 bucket configuration for raw-tracks recordings" + ) + + +class CreateRoomRequest(BaseModel): + """ + Request to create a new Daily.co room. + + Reference: https://docs.daily.co/reference/rest-api/rooms/create-room + """ + + name: NonEmptyString = Field(description="Room name (must be unique within domain)") + privacy: Literal["public", "private"] = Field( + default="public", description="Room privacy setting" + ) + properties: RoomProperties = Field( + default_factory=RoomProperties, description="Room configuration properties" + ) + + +class MeetingTokenProperties(BaseModel): + """ + Properties for meeting token creation. + + Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token + """ + + room_name: NonEmptyString = Field(description="Room name this token is valid for") + user_id: NonEmptyString | None = Field( + None, description="User identifier to associate with token" + ) + is_owner: bool = Field( + default=False, description="Grant owner privileges to token holder" + ) + start_cloud_recording: bool = Field( + default=False, description="Automatically start cloud recording on join" + ) + enable_recording_ui: bool = Field( + default=True, description="Show recording controls in UI" + ) + eject_at_token_exp: bool = Field( + default=False, description="Eject participant when token expires" + ) + nbf: int | None = Field( + None, description="Not-before timestamp (Unix epoch seconds)" + ) + exp: int | None = Field( + None, description="Expiration timestamp (Unix epoch seconds)" + ) + + +class CreateMeetingTokenRequest(BaseModel): + """ + Request to create a meeting token for participant authentication. + + Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token + """ + + properties: MeetingTokenProperties = Field(description="Token properties") + + +class CreateWebhookRequest(BaseModel): + """ + Request to create a webhook subscription. + + Reference: https://docs.daily.co/reference/rest-api/webhooks + """ + + url: NonEmptyString = Field(description="Webhook endpoint URL (must be HTTPS)") + eventTypes: List[ + Literal[ + "participant.joined", + "participant.left", + "recording.started", + "recording.ready-to-download", + "recording.error", + ] + ] = Field( + description="Array of event types to subscribe to (only events we handle)" + ) + hmac: NonEmptyString = Field( + description="Base64-encoded HMAC secret for webhook signature verification" + ) + basicAuth: NonEmptyString | None = Field( + None, description="Optional basic auth credentials for webhook endpoint" + ) + + +class UpdateWebhookRequest(BaseModel): + """ + Request to update an existing webhook. + + Note: Daily.co API may not support PATCH for webhooks. + Common pattern is to delete and recreate. + + Reference: https://docs.daily.co/reference/rest-api/webhooks + """ + + url: NonEmptyString | None = Field(None, description="New webhook endpoint URL") + eventTypes: List[NonEmptyString] | None = Field( + None, description="New array of event types" + ) + hmac: NonEmptyString | None = Field(None, description="New HMAC secret") + basicAuth: NonEmptyString | None = Field( + None, description="New basic auth credentials" + ) diff --git a/server/reflector/dailyco_api/responses.py b/server/reflector/dailyco_api/responses.py new file mode 100644 index 00000000..4eb84245 --- /dev/null +++ b/server/reflector/dailyco_api/responses.py @@ -0,0 +1,182 @@ +""" +Daily.co API Response Models +""" + +from typing import Any, Dict, List, Literal + +from pydantic import BaseModel, Field + +from reflector.utils.string import NonEmptyString + +# not documented in daily; we fill it according to observations +RecordingStatus = Literal["in-progress", "finished"] + + +class RoomResponse(BaseModel): + """ + Response from room creation or retrieval. + + Reference: https://docs.daily.co/reference/rest-api/rooms/create-room + """ + + id: NonEmptyString = Field(description="Unique room identifier (UUID)") + name: NonEmptyString = Field(description="Room name used in URLs") + api_created: bool = Field(description="Whether room was created via API") + privacy: Literal["public", "private"] = Field(description="Room privacy setting") + url: NonEmptyString = Field(description="Full room URL") + created_at: NonEmptyString = Field(description="ISO 8601 creation timestamp") + config: Dict[NonEmptyString, Any] = Field( + default_factory=dict, description="Room configuration properties" + ) + + +class RoomPresenceParticipant(BaseModel): + """ + Participant presence information in a room. + + Reference: https://docs.daily.co/reference/rest-api/rooms/get-room-presence + """ + + room: NonEmptyString = Field(description="Room name") + id: NonEmptyString = Field(description="Participant session ID") + userId: NonEmptyString | None = Field(None, description="User ID if provided") + userName: NonEmptyString | None = Field(None, description="User display name") + joinTime: NonEmptyString = Field(description="ISO 8601 join timestamp") + duration: int = Field(description="Duration in room (seconds)") + + +class RoomPresenceResponse(BaseModel): + """ + Response from room presence endpoint. + + Reference: https://docs.daily.co/reference/rest-api/rooms/get-room-presence + """ + + total_count: int = Field( + description="Total number of participants currently in room" + ) + data: List[RoomPresenceParticipant] = Field( + default_factory=list, description="Array of participant presence data" + ) + + +class MeetingParticipant(BaseModel): + """ + Historical participant data from a meeting. + + Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants + """ + + user_id: NonEmptyString = Field(description="User identifier") + participant_id: NonEmptyString = Field(description="Participant session identifier") + user_name: NonEmptyString | None = Field(None, description="User display name") + join_time: int = Field(description="Join timestamp (Unix epoch seconds)") + duration: int = Field(description="Duration in meeting (seconds)") + + +class MeetingParticipantsResponse(BaseModel): + """ + Response from meeting participants endpoint. + + Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants + """ + + data: List[MeetingParticipant] = Field( + default_factory=list, description="Array of participant data" + ) + + +class MeetingResponse(BaseModel): + """ + Response from meeting information endpoint. + + Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-information + """ + + id: NonEmptyString = Field(description="Meeting session identifier (UUID)") + room: NonEmptyString = Field(description="Room name where meeting occurred") + start_time: int = Field( + description="Meeting start Unix timestamp (~15s granularity)" + ) + duration: int = Field(description="Total meeting duration in seconds") + ongoing: bool = Field(description="Whether meeting is currently active") + max_participants: int = Field(description="Peak concurrent participant count") + participants: List[MeetingParticipant] = Field( + default_factory=list, description="Array of participant session data" + ) + + +class RecordingS3Info(BaseModel): + """ + S3 bucket information for a recording. + + Reference: https://docs.daily.co/reference/rest-api/recordings + """ + + bucket_name: NonEmptyString + bucket_region: NonEmptyString + endpoint: NonEmptyString | None = None + + +class RecordingResponse(BaseModel): + """ + Response from recording retrieval endpoint. + + Reference: https://docs.daily.co/reference/rest-api/recordings + """ + + id: NonEmptyString = Field(description="Recording identifier") + room_name: NonEmptyString = Field(description="Room where recording occurred") + start_ts: int = Field(description="Recording start timestamp (Unix epoch seconds)") + status: RecordingStatus = Field( + description="Recording status ('in-progress' or 'finished')" + ) + max_participants: int = Field(description="Maximum participants during recording") + duration: int = Field(description="Recording duration in seconds") + share_token: NonEmptyString | None = Field( + None, description="Token for sharing recording" + ) + s3: RecordingS3Info | None = Field(None, description="S3 bucket information") + + +class MeetingTokenResponse(BaseModel): + """ + Response from meeting token creation. + + Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token + """ + + token: NonEmptyString = Field( + description="JWT meeting token for participant authentication" + ) + + +class WebhookResponse(BaseModel): + """ + Response from webhook creation or retrieval. + + Reference: https://docs.daily.co/reference/rest-api/webhooks + """ + + uuid: NonEmptyString = Field(description="Unique webhook identifier") + url: NonEmptyString = Field(description="Webhook endpoint URL") + hmac: NonEmptyString | None = Field( + None, description="Base64-encoded HMAC secret for signature verification" + ) + basicAuth: NonEmptyString | None = Field( + None, description="Basic auth credentials if configured" + ) + eventTypes: List[NonEmptyString] = Field( + default_factory=list, + description="Array of event types (e.g., ['recording.started', 'participant.joined'])", + ) + state: Literal["ACTIVE", "FAILED"] = Field( + description="Webhook state - FAILED after 3+ consecutive failures" + ) + failedCount: int = Field(default=0, description="Number of consecutive failures") + lastMomentPushed: NonEmptyString | None = Field( + None, description="ISO 8601 timestamp of last successful push" + ) + domainId: NonEmptyString = Field(description="Daily.co domain/account identifier") + createdAt: NonEmptyString = Field(description="ISO 8601 creation timestamp") + updatedAt: NonEmptyString = Field(description="ISO 8601 last update timestamp") diff --git a/server/reflector/dailyco_api/webhook_utils.py b/server/reflector/dailyco_api/webhook_utils.py new file mode 100644 index 00000000..b10d4fa2 --- /dev/null +++ b/server/reflector/dailyco_api/webhook_utils.py @@ -0,0 +1,229 @@ +""" +Daily.co Webhook Utilities + +Utilities for verifying and parsing Daily.co webhook events. + +Reference: https://docs.daily.co/reference/rest-api/webhooks +""" + +import base64 +import hmac +from hashlib import sha256 + +import structlog + +from .webhooks import ( + DailyWebhookEvent, + ParticipantJoinedPayload, + ParticipantLeftPayload, + RecordingErrorPayload, + RecordingReadyToDownloadPayload, + RecordingStartedPayload, +) + +logger = structlog.get_logger(__name__) + + +def verify_webhook_signature( + body: bytes, + signature: str, + timestamp: str, + webhook_secret: str, +) -> bool: + """ + Verify Daily.co webhook signature using HMAC-SHA256. + + Daily.co signature verification: + 1. Base64-decode the webhook secret + 2. Create signed content: timestamp + '.' + body + 3. Compute HMAC-SHA256(secret, signed_content) + 4. Base64-encode the result + 5. Compare with provided signature using constant-time comparison + + Reference: https://docs.daily.co/reference/rest-api/webhooks + + Args: + body: Raw request body bytes + signature: X-Webhook-Signature header value + timestamp: X-Webhook-Timestamp header value + webhook_secret: Base64-encoded HMAC secret + + Returns: + True if signature is valid, False otherwise + + Example: + >>> body = b'{"version":"1.0.0","type":"participant.joined",...}' + >>> signature = "abc123..." + >>> timestamp = "1234567890" + >>> secret = "your-base64-secret" + >>> is_valid = verify_webhook_signature(body, signature, timestamp, secret) + """ + if not signature or not timestamp or not webhook_secret: + logger.warning( + "Missing required data for webhook verification", + has_signature=bool(signature), + has_timestamp=bool(timestamp), + has_secret=bool(webhook_secret), + ) + return False + + try: + secret_bytes = base64.b64decode(webhook_secret) + signed_content = timestamp.encode() + b"." + body + expected = hmac.new(secret_bytes, signed_content, sha256).digest() + expected_b64 = base64.b64encode(expected).decode() + + # Constant-time comparison to prevent timing attacks + return hmac.compare_digest(expected_b64, signature) + + except (base64.binascii.Error, ValueError, TypeError, UnicodeDecodeError) as e: + logger.error( + "Webhook signature verification failed", + error=str(e), + error_type=type(e).__name__, + ) + return False + + +def extract_room_name(event: DailyWebhookEvent) -> str | None: + """ + Extract room name from Daily.co webhook event payload. + + Args: + event: Parsed webhook event + + Returns: + Room name if present and is a string, None otherwise + + Example: + >>> event = DailyWebhookEvent(**webhook_payload) + >>> room_name = extract_room_name(event) + """ + room = event.payload.get("room_name") + # Ensure we return a string, not any falsy value that might be in payload + return room if isinstance(room, str) else None + + +def parse_participant_joined(event: DailyWebhookEvent) -> ParticipantJoinedPayload: + """ + Parse participant.joined webhook event payload. + + Args: + event: Webhook event with type "participant.joined" + + Returns: + Parsed participant joined payload + + Raises: + pydantic.ValidationError: If payload doesn't match expected schema + """ + return ParticipantJoinedPayload(**event.payload) + + +def parse_participant_left(event: DailyWebhookEvent) -> ParticipantLeftPayload: + """ + Parse participant.left webhook event payload. + + Args: + event: Webhook event with type "participant.left" + + Returns: + Parsed participant left payload + + Raises: + pydantic.ValidationError: If payload doesn't match expected schema + """ + return ParticipantLeftPayload(**event.payload) + + +def parse_recording_started(event: DailyWebhookEvent) -> RecordingStartedPayload: + """ + Parse recording.started webhook event payload. + + Args: + event: Webhook event with type "recording.started" + + Returns: + Parsed recording started payload + + Raises: + pydantic.ValidationError: If payload doesn't match expected schema + """ + return RecordingStartedPayload(**event.payload) + + +def parse_recording_ready( + event: DailyWebhookEvent, +) -> RecordingReadyToDownloadPayload: + """ + Parse recording.ready-to-download webhook event payload. + + This event is sent when raw-tracks recordings are complete and uploaded to S3. + The payload includes a 'tracks' array with individual audio/video files. + + Args: + event: Webhook event with type "recording.ready-to-download" + + Returns: + Parsed recording ready payload with tracks array + + Raises: + pydantic.ValidationError: If payload doesn't match expected schema + + Example: + >>> event = DailyWebhookEvent(**webhook_payload) + >>> if event.type == "recording.ready-to-download": + ... payload = parse_recording_ready(event) + ... audio_tracks = [t for t in payload.tracks if t.type == "audio"] + """ + return RecordingReadyToDownloadPayload(**event.payload) + + +def parse_recording_error(event: DailyWebhookEvent) -> RecordingErrorPayload: + """ + Parse recording.error webhook event payload. + + Args: + event: Webhook event with type "recording.error" + + Returns: + Parsed recording error payload + + Raises: + pydantic.ValidationError: If payload doesn't match expected schema + """ + return RecordingErrorPayload(**event.payload) + + +# Webhook event type to parser mapping +WEBHOOK_PARSERS = { + "participant.joined": parse_participant_joined, + "participant.left": parse_participant_left, + "recording.started": parse_recording_started, + "recording.ready-to-download": parse_recording_ready, + "recording.error": parse_recording_error, +} + + +def parse_webhook_payload(event: DailyWebhookEvent): + """ + Parse webhook event payload based on event type. + + Args: + event: Webhook event + + Returns: + Typed payload model based on event type, or raw dict if unknown + + Example: + >>> event = DailyWebhookEvent(**webhook_payload) + >>> payload = parse_webhook_payload(event) + >>> if isinstance(payload, ParticipantJoinedPayload): + ... print(f"User {payload.user_name} joined") + """ + parser = WEBHOOK_PARSERS.get(event.type) + if parser: + return parser(event) + else: + logger.warning("Unknown webhook event type", event_type=event.type) + return event.payload diff --git a/server/reflector/dailyco_api/webhooks.py b/server/reflector/dailyco_api/webhooks.py new file mode 100644 index 00000000..862f4996 --- /dev/null +++ b/server/reflector/dailyco_api/webhooks.py @@ -0,0 +1,199 @@ +""" +Daily.co Webhook Event Models + +Reference: https://docs.daily.co/reference/rest-api/webhooks +""" + +from typing import Any, Dict, Literal + +from pydantic import BaseModel, Field, field_validator + +from reflector.utils.string import NonEmptyString + + +def normalize_timestamp_to_int(v): + """ + Normalize float timestamps to int by truncating decimal part. + + Daily.co sometimes sends timestamps as floats (e.g., 1708972279.96). + Pydantic expects int for fields typed as `int`. + """ + if v is None: + return v + if isinstance(v, float): + return int(v) + return v + + +WebhookEventType = Literal[ + "participant.joined", + "participant.left", + "recording.started", + "recording.ready-to-download", + "recording.error", +] + + +class DailyTrack(BaseModel): + """ + Individual audio or video track from a multitrack recording. + + Reference: https://docs.daily.co/reference/rest-api/recordings + """ + + type: Literal["audio", "video"] + s3Key: NonEmptyString = Field(description="S3 object key for the track file") + size: int = Field(description="File size in bytes") + + +class DailyWebhookEvent(BaseModel): + """ + Base structure for all Daily.co webhook events. + All events share five common fields documented below. + + Reference: https://docs.daily.co/reference/rest-api/webhooks + """ + + version: NonEmptyString = Field( + description="Represents the version of the event. This uses semantic versioning to inform a consumer if the payload has introduced any breaking changes" + ) + type: WebhookEventType = Field( + description="Represents the type of the event described in the payload" + ) + id: NonEmptyString = Field( + description="An identifier representing this specific event" + ) + payload: Dict[NonEmptyString, Any] = Field( + description="An object representing the event, whose fields are described in the corresponding payload class" + ) + event_ts: int = Field( + description="Documenting when the webhook itself was sent. This timestamp is different than the time of the event the webhook describes. For example, a recording.started event will contain a start_ts timestamp of when the actual recording started, and a slightly later event_ts timestamp indicating when the webhook event was sent" + ) + + _normalize_event_ts = field_validator("event_ts", mode="before")( + normalize_timestamp_to_int + ) + + +class ParticipantJoinedPayload(BaseModel): + """ + Payload for participant.joined webhook event. + + Reference: https://docs.daily.co/reference/rest-api/webhooks/events/participant-joined + """ + + room_name: NonEmptyString | None = Field(None, description="Daily.co room name") + session_id: NonEmptyString = Field(description="Daily.co session identifier") + user_id: NonEmptyString = Field(description="User identifier (may be encoded)") + user_name: NonEmptyString | None = Field(None, description="User display name") + joined_at: int = Field(description="Join timestamp in Unix epoch seconds") + + _normalize_joined_at = field_validator("joined_at", mode="before")( + normalize_timestamp_to_int + ) + + +class ParticipantLeftPayload(BaseModel): + """ + Payload for participant.left webhook event. + + Reference: https://docs.daily.co/reference/rest-api/webhooks/events/participant-left + """ + + room_name: NonEmptyString | None = Field(None, description="Daily.co room name") + session_id: NonEmptyString = Field(description="Daily.co session identifier") + user_id: NonEmptyString = Field(description="User identifier (may be encoded)") + user_name: NonEmptyString | None = Field(None, description="User display name") + joined_at: int = Field(description="Join timestamp in Unix epoch seconds") + duration: int | None = Field( + None, description="Duration of participation in seconds" + ) + + _normalize_joined_at = field_validator("joined_at", mode="before")( + normalize_timestamp_to_int + ) + + +class RecordingStartedPayload(BaseModel): + """ + Payload for recording.started webhook event. + + Reference: https://docs.daily.co/reference/rest-api/webhooks/events/recording-started + """ + + room_name: NonEmptyString | None = Field(None, description="Daily.co room name") + recording_id: NonEmptyString = Field(description="Recording identifier") + start_ts: int | None = Field(None, description="Recording start timestamp") + + _normalize_start_ts = field_validator("start_ts", mode="before")( + normalize_timestamp_to_int + ) + + +class RecordingReadyToDownloadPayload(BaseModel): + """ + Payload for recording.ready-to-download webhook event. + This is sent when raw-tracks recordings are complete and uploaded to S3. + + Reference: https://docs.daily.co/reference/rest-api/webhooks/events/recording-ready-to-download + """ + + type: Literal["cloud", "raw-tracks"] = Field( + description="The type of recording that was generated" + ) + recording_id: NonEmptyString = Field( + description="An ID identifying the recording that was generated" + ) + room_name: NonEmptyString = Field( + description="The name of the room where the recording was made" + ) + start_ts: int = Field( + description="The Unix epoch time in seconds representing when the recording started" + ) + status: Literal["finished"] = Field( + description="The status of the given recording (always 'finished' in ready-to-download webhook, see RecordingStatus in responses.py for full API statuses)" + ) + max_participants: int = Field( + description="The number of participants on the call that were recorded" + ) + duration: int = Field(description="The duration in seconds of the call") + s3_key: NonEmptyString = Field( + description="The location of the recording in the provided S3 bucket" + ) + share_token: NonEmptyString | None = Field( + None, description="undocumented documented secret field" + ) + tracks: list[DailyTrack] | None = Field( + None, + description="If the recording is a raw-tracks recording, a tracks field will be provided. If role permissions have been removed, the tracks field may be null", + ) + + _normalize_start_ts = field_validator("start_ts", mode="before")( + normalize_timestamp_to_int + ) + + +class RecordingErrorPayload(BaseModel): + """ + Payload for recording.error webhook event. + + Reference: https://docs.daily.co/reference/rest-api/webhooks/events/recording-error + """ + + action: Literal["clourd-recording-err", "cloud-recording-error"] = Field( + description="A string describing the event that was emitted (both variants are documented)" + ) + error_msg: NonEmptyString = Field(description="The error message returned") + instance_id: NonEmptyString = Field( + description="The recording instance ID that was passed into the start recording command" + ) + room_name: NonEmptyString = Field( + description="The name of the room where the recording was made" + ) + timestamp: int = Field( + description="The Unix epoch time in seconds representing when the error was emitted" + ) + + _normalize_timestamp = field_validator("timestamp", mode="before")( + normalize_timestamp_to_int + ) diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py index da488a51..91ed12ee 100644 --- a/server/reflector/db/__init__.py +++ b/server/reflector/db/__init__.py @@ -24,10 +24,13 @@ def get_database() -> databases.Database: # import models +import reflector.db.calendar_events # noqa +import reflector.db.daily_participant_sessions # noqa import reflector.db.meetings # noqa import reflector.db.recordings # noqa import reflector.db.rooms # noqa import reflector.db.transcripts # noqa +import reflector.db.user_api_keys # noqa kwargs = {} if "postgres" not in settings.DATABASE_URL: diff --git a/server/reflector/db/calendar_events.py b/server/reflector/db/calendar_events.py new file mode 100644 index 00000000..3eddc3f1 --- /dev/null +++ b/server/reflector/db/calendar_events.py @@ -0,0 +1,187 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +import sqlalchemy as sa +from pydantic import BaseModel, Field +from sqlalchemy.dialects.postgresql import JSONB + +from reflector.db import get_database, metadata +from reflector.utils import generate_uuid4 + +calendar_events = sa.Table( + "calendar_event", + metadata, + sa.Column("id", sa.String, primary_key=True), + sa.Column( + "room_id", + sa.String, + sa.ForeignKey("room.id", ondelete="CASCADE", name="fk_calendar_event_room_id"), + nullable=False, + ), + sa.Column("ics_uid", sa.Text, nullable=False), + sa.Column("title", sa.Text), + sa.Column("description", sa.Text), + sa.Column("start_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("end_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("attendees", JSONB), + sa.Column("location", sa.Text), + sa.Column("ics_raw_data", sa.Text), + sa.Column("last_synced", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_deleted", sa.Boolean, nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("room_id", "ics_uid", name="uq_room_calendar_event"), + sa.Index("idx_calendar_event_room_start", "room_id", "start_time"), + sa.Index( + "idx_calendar_event_deleted", + "is_deleted", + postgresql_where=sa.text("NOT is_deleted"), + ), +) + + +class CalendarEvent(BaseModel): + id: str = Field(default_factory=generate_uuid4) + room_id: str + ics_uid: str + title: str | None = None + description: str | None = None + start_time: datetime + end_time: datetime + attendees: list[dict[str, Any]] | None = None + location: str | None = None + ics_raw_data: str | None = None + last_synced: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + is_deleted: bool = False + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class CalendarEventController: + async def get_by_room( + self, + room_id: str, + include_deleted: bool = False, + start_after: datetime | None = None, + end_before: datetime | None = None, + ) -> list[CalendarEvent]: + query = calendar_events.select().where(calendar_events.c.room_id == room_id) + + if not include_deleted: + query = query.where(calendar_events.c.is_deleted == False) + + if start_after: + query = query.where(calendar_events.c.start_time >= start_after) + + if end_before: + query = query.where(calendar_events.c.end_time <= end_before) + + query = query.order_by(calendar_events.c.start_time.asc()) + + results = await get_database().fetch_all(query) + return [CalendarEvent(**result) for result in results] + + async def get_upcoming( + self, room_id: str, minutes_ahead: int = 120 + ) -> list[CalendarEvent]: + """Get upcoming events for a room within the specified minutes, including currently happening events.""" + now = datetime.now(timezone.utc) + future_time = now + timedelta(minutes=minutes_ahead) + + query = ( + calendar_events.select() + .where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.is_deleted == False, + calendar_events.c.start_time <= future_time, + calendar_events.c.end_time >= now, + ) + ) + .order_by(calendar_events.c.start_time.asc()) + ) + + results = await get_database().fetch_all(query) + return [CalendarEvent(**result) for result in results] + + async def get_by_id(self, event_id: str) -> CalendarEvent | None: + query = calendar_events.select().where(calendar_events.c.id == event_id) + result = await get_database().fetch_one(query) + return CalendarEvent(**result) if result else None + + async def get_by_ics_uid(self, room_id: str, ics_uid: str) -> CalendarEvent | None: + query = calendar_events.select().where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.ics_uid == ics_uid, + ) + ) + result = await get_database().fetch_one(query) + return CalendarEvent(**result) if result else None + + async def upsert(self, event: CalendarEvent) -> CalendarEvent: + existing = await self.get_by_ics_uid(event.room_id, event.ics_uid) + + if existing: + event.id = existing.id + event.created_at = existing.created_at + event.updated_at = datetime.now(timezone.utc) + + query = ( + calendar_events.update() + .where(calendar_events.c.id == existing.id) + .values(**event.model_dump()) + ) + else: + query = calendar_events.insert().values(**event.model_dump()) + + await get_database().execute(query) + return event + + async def soft_delete_missing( + self, room_id: str, current_ics_uids: list[str] + ) -> int: + """Soft delete future events that are no longer in the calendar.""" + now = datetime.now(timezone.utc) + + select_query = calendar_events.select().where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.start_time > now, + calendar_events.c.is_deleted == False, + calendar_events.c.ics_uid.notin_(current_ics_uids) + if current_ics_uids + else True, + ) + ) + + to_delete = await get_database().fetch_all(select_query) + delete_count = len(to_delete) + + if delete_count > 0: + update_query = ( + calendar_events.update() + .where( + sa.and_( + calendar_events.c.room_id == room_id, + calendar_events.c.start_time > now, + calendar_events.c.is_deleted == False, + calendar_events.c.ics_uid.notin_(current_ics_uids) + if current_ics_uids + else True, + ) + ) + .values(is_deleted=True, updated_at=now) + ) + + await get_database().execute(update_query) + + return delete_count + + async def delete_by_room(self, room_id: str) -> int: + query = calendar_events.delete().where(calendar_events.c.room_id == room_id) + result = await get_database().execute(query) + return result.rowcount + + +calendar_events_controller = CalendarEventController() diff --git a/server/reflector/db/daily_participant_sessions.py b/server/reflector/db/daily_participant_sessions.py new file mode 100644 index 00000000..5fac1912 --- /dev/null +++ b/server/reflector/db/daily_participant_sessions.py @@ -0,0 +1,169 @@ +"""Daily.co participant session tracking. + +Stores webhook data for participant.joined and participant.left events to provide +historical session information (Daily.co API only returns current participants). +""" + +from datetime import datetime + +import sqlalchemy as sa +from pydantic import BaseModel +from sqlalchemy.dialects.postgresql import insert + +from reflector.db import get_database, metadata +from reflector.utils.string import NonEmptyString + +daily_participant_sessions = sa.Table( + "daily_participant_session", + metadata, + sa.Column("id", sa.String, primary_key=True), + sa.Column( + "meeting_id", + sa.String, + sa.ForeignKey("meeting.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "room_id", + sa.String, + sa.ForeignKey("room.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("session_id", sa.String, nullable=False), + sa.Column("user_id", sa.String, nullable=True), + sa.Column("user_name", sa.String, nullable=False), + sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("left_at", sa.DateTime(timezone=True), nullable=True), + sa.Index("idx_daily_session_meeting_left", "meeting_id", "left_at"), + sa.Index("idx_daily_session_room", "room_id"), +) + + +class DailyParticipantSession(BaseModel): + """Daily.co participant session record. + + Tracks when a participant joined and left a meeting. Populated from webhooks: + - participant.joined: Creates record with left_at=None + - participant.left: Updates record with left_at + + ID format: {meeting_id}:{user_id}:{joined_at_ms} + - Ensures idempotency (duplicate webhooks don't create duplicates) + - Allows same user to rejoin (different joined_at = different session) + + Duration is calculated as: left_at - joined_at (not stored) + """ + + id: NonEmptyString + meeting_id: NonEmptyString + room_id: NonEmptyString + session_id: NonEmptyString # Daily.co's session_id (identifies room session) + user_id: NonEmptyString | None = None + user_name: str + joined_at: datetime + left_at: datetime | None = None + + +class DailyParticipantSessionController: + """Controller for Daily.co participant session persistence.""" + + async def get_by_id(self, id: str) -> DailyParticipantSession | None: + """Get a session by its ID.""" + query = daily_participant_sessions.select().where( + daily_participant_sessions.c.id == id + ) + result = await get_database().fetch_one(query) + return DailyParticipantSession(**result) if result else None + + async def get_open_session( + self, meeting_id: NonEmptyString, session_id: NonEmptyString + ) -> DailyParticipantSession | None: + """Get the open (not left) session for a user in a meeting.""" + query = daily_participant_sessions.select().where( + sa.and_( + daily_participant_sessions.c.meeting_id == meeting_id, + daily_participant_sessions.c.session_id == session_id, + daily_participant_sessions.c.left_at.is_(None), + ) + ) + results = await get_database().fetch_all(query) + + if len(results) > 1: + raise ValueError( + f"Multiple open sessions for daily session {session_id} in meeting {meeting_id}: " + f"found {len(results)} sessions" + ) + + return DailyParticipantSession(**results[0]) if results else None + + async def upsert_joined(self, session: DailyParticipantSession) -> None: + """Insert or update when participant.joined webhook arrives. + + Idempotent: Duplicate webhooks with same ID are safely ignored. + Out-of-order: If left webhook arrived first, preserves left_at. + """ + query = insert(daily_participant_sessions).values(**session.model_dump()) + query = query.on_conflict_do_update( + index_elements=["id"], + set_={"user_name": session.user_name}, + ) + await get_database().execute(query) + + async def upsert_left(self, session: DailyParticipantSession) -> None: + """Update session when participant.left webhook arrives. + + Finds the open session for this user in this meeting and updates left_at. + Works around Daily.co webhook timestamp inconsistency (joined_at differs by ~4ms between webhooks). + + Handles three cases: + 1. Normal flow: open session exists → updates left_at + 2. Out-of-order: left arrives first → creates new record with left data + 3. Duplicate: left arrives again → idempotent (DB trigger prevents left_at modification) + """ + if session.left_at is None: + raise ValueError("left_at is required for upsert_left") + + if session.left_at <= session.joined_at: + raise ValueError( + f"left_at ({session.left_at}) must be after joined_at ({session.joined_at})" + ) + + # Find existing open session (works around timestamp mismatch in webhooks) + existing = await self.get_open_session(session.meeting_id, session.session_id) + + if existing: + # Update existing open session + query = ( + daily_participant_sessions.update() + .where(daily_participant_sessions.c.id == existing.id) + .values(left_at=session.left_at) + ) + await get_database().execute(query) + else: + # Out-of-order or first webhook: insert new record + query = insert(daily_participant_sessions).values(**session.model_dump()) + query = query.on_conflict_do_nothing(index_elements=["id"]) + await get_database().execute(query) + + async def get_by_meeting(self, meeting_id: str) -> list[DailyParticipantSession]: + """Get all participant sessions for a meeting (active and ended).""" + query = daily_participant_sessions.select().where( + daily_participant_sessions.c.meeting_id == meeting_id + ) + results = await get_database().fetch_all(query) + return [DailyParticipantSession(**result) for result in results] + + async def get_active_by_meeting( + self, meeting_id: str + ) -> list[DailyParticipantSession]: + """Get only active (not left) participant sessions for a meeting.""" + query = daily_participant_sessions.select().where( + sa.and_( + daily_participant_sessions.c.meeting_id == meeting_id, + daily_participant_sessions.c.left_at.is_(None), + ) + ) + results = await get_database().fetch_all(query) + return [DailyParticipantSession(**result) for result in results] + + +daily_participant_sessions_controller = DailyParticipantSessionController() diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py index 40bd6f8a..6912b285 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -1,13 +1,16 @@ from datetime import datetime -from typing import Literal +from typing import Any, Literal import sqlalchemy as sa -from fastapi import HTTPException from pydantic import BaseModel, Field +from sqlalchemy.dialects.postgresql import JSONB from reflector.db import get_database, metadata from reflector.db.rooms import Room +from reflector.schemas.platform import WHEREBY_PLATFORM, Platform from reflector.utils import generate_uuid4 +from reflector.utils.string import assert_equal +from reflector.video_platforms.factory import get_platform meetings = sa.Table( "meeting", @@ -18,8 +21,12 @@ meetings = sa.Table( sa.Column("host_room_url", sa.String), sa.Column("start_date", sa.DateTime(timezone=True)), sa.Column("end_date", sa.DateTime(timezone=True)), - sa.Column("user_id", sa.String), - sa.Column("room_id", sa.String), + sa.Column( + "room_id", + sa.String, + sa.ForeignKey("room.id", ondelete="CASCADE"), + nullable=True, + ), sa.Column("is_locked", sa.Boolean, nullable=False, server_default=sa.false()), sa.Column("room_mode", sa.String, nullable=False, server_default="normal"), sa.Column("recording_type", sa.String, nullable=False, server_default="cloud"), @@ -41,20 +48,36 @@ meetings = sa.Table( nullable=False, server_default=sa.true(), ), - sa.Index("idx_meeting_room_id", "room_id"), - sa.Index( - "idx_one_active_meeting_per_room", - "room_id", - unique=True, - postgresql_where=sa.text("is_active = true"), + sa.Column( + "calendar_event_id", + sa.String, + sa.ForeignKey( + "calendar_event.id", + ondelete="SET NULL", + name="fk_meeting_calendar_event_id", + ), ), + sa.Column("calendar_metadata", JSONB), + sa.Column( + "platform", + sa.String, + nullable=False, + server_default=assert_equal(WHEREBY_PLATFORM, "whereby"), + ), + sa.Index("idx_meeting_room_id", "room_id"), + sa.Index("idx_meeting_calendar_event", "calendar_event_id"), ) meeting_consent = sa.Table( "meeting_consent", metadata, sa.Column("id", sa.String, primary_key=True), - sa.Column("meeting_id", sa.String, sa.ForeignKey("meeting.id"), nullable=False), + sa.Column( + "meeting_id", + sa.String, + sa.ForeignKey("meeting.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("user_id", sa.String), sa.Column("consent_given", sa.Boolean, nullable=False), sa.Column("consent_timestamp", sa.DateTime(timezone=True), nullable=False), @@ -76,15 +99,18 @@ class Meeting(BaseModel): host_room_url: str start_date: datetime end_date: datetime - user_id: str | None = None - room_id: str | None = None + room_id: str | None is_locked: bool = False room_mode: Literal["normal", "group"] = "normal" recording_type: Literal["none", "local", "cloud"] = "cloud" - recording_trigger: Literal[ + recording_trigger: Literal[ # whereby-specific "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" num_clients: int = 0 + is_active: bool = True + calendar_event_id: str | None = None + calendar_metadata: dict[str, Any] | None = None + platform: Platform = WHEREBY_PLATFORM class MeetingController: @@ -96,12 +122,10 @@ class MeetingController: host_room_url: str, start_date: datetime, end_date: datetime, - user_id: str, room: Room, + calendar_event_id: str | None = None, + calendar_metadata: dict[str, Any] | None = None, ): - """ - Create a new meeting - """ meeting = Meeting( id=id, room_name=room_name, @@ -109,41 +133,46 @@ class MeetingController: host_room_url=host_room_url, start_date=start_date, end_date=end_date, - user_id=user_id, room_id=room.id, is_locked=room.is_locked, room_mode=room.room_mode, recording_type=room.recording_type, recording_trigger=room.recording_trigger, + calendar_event_id=calendar_event_id, + calendar_metadata=calendar_metadata, + platform=get_platform(room.platform), ) query = meetings.insert().values(**meeting.model_dump()) await get_database().execute(query) return meeting async def get_all_active(self) -> list[Meeting]: - """ - Get active meetings. - """ query = meetings.select().where(meetings.c.is_active) - return await get_database().fetch_all(query) + results = await get_database().fetch_all(query) + return [Meeting(**result) for result in results] async def get_by_room_name( self, room_name: str, - ) -> Meeting: + ) -> Meeting | None: """ Get a meeting by room name. + For backward compatibility, returns the most recent meeting. """ - query = meetings.select().where(meetings.c.room_name == room_name) + query = ( + meetings.select() + .where(meetings.c.room_name == room_name) + .order_by(meetings.c.end_date.desc()) + ) result = await get_database().fetch_one(query) if not result: return None - return Meeting(**result) - async def get_active(self, room: Room, current_time: datetime) -> Meeting: + async def get_active(self, room: Room, current_time: datetime) -> Meeting | None: """ Get latest active meeting for a room. + For backward compatibility, returns the most recent active meeting. """ end_date = getattr(meetings.c, "end_date") query = ( @@ -160,40 +189,97 @@ class MeetingController: result = await get_database().fetch_one(query) if not result: return None - return Meeting(**result) - async def get_by_id(self, meeting_id: str, **kwargs) -> Meeting | None: + async def get_all_active_for_room( + self, room: Room, current_time: datetime + ) -> list[Meeting]: + end_date = getattr(meetings.c, "end_date") + query = ( + meetings.select() + .where( + sa.and_( + meetings.c.room_id == room.id, + meetings.c.end_date > current_time, + meetings.c.is_active, + ) + ) + .order_by(end_date.desc()) + ) + results = await get_database().fetch_all(query) + return [Meeting(**result) for result in results] + + async def get_active_by_calendar_event( + self, room: Room, calendar_event_id: str, current_time: datetime + ) -> Meeting | None: """ - Get a meeting by id + Get active meeting for a specific calendar event. """ - query = meetings.select().where(meetings.c.id == meeting_id) + query = meetings.select().where( + sa.and_( + meetings.c.room_id == room.id, + meetings.c.calendar_event_id == calendar_event_id, + meetings.c.end_date > current_time, + meetings.c.is_active, + ) + ) result = await get_database().fetch_one(query) if not result: return None return Meeting(**result) - async def get_by_id_for_http(self, meeting_id: str, user_id: str | None) -> Meeting: - """ - Get a meeting by ID for HTTP request. - - If not found, it will raise a 404 error. - """ + async def get_by_id( + self, meeting_id: str, room: Room | None = None + ) -> Meeting | None: query = meetings.select().where(meetings.c.id == meeting_id) + + if room: + query = query.where(meetings.c.room_id == room.id) + result = await get_database().fetch_one(query) if not result: - raise HTTPException(status_code=404, detail="Meeting not found") + return None + return Meeting(**result) - meeting = Meeting(**result) - if result["user_id"] != user_id: - meeting.host_room_url = "" - - return meeting + async def get_by_calendar_event( + self, calendar_event_id: str, room: Room + ) -> Meeting | None: + query = meetings.select().where( + meetings.c.calendar_event_id == calendar_event_id + ) + if room: + query = query.where(meetings.c.room_id == room.id) + result = await get_database().fetch_one(query) + if not result: + return None + return Meeting(**result) async def update_meeting(self, meeting_id: str, **kwargs): query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs) await get_database().execute(query) + async def increment_num_clients(self, meeting_id: str) -> None: + """Atomically increment participant count.""" + query = ( + meetings.update() + .where(meetings.c.id == meeting_id) + .values(num_clients=meetings.c.num_clients + 1) + ) + await get_database().execute(query) + + async def decrement_num_clients(self, meeting_id: str) -> None: + """Atomically decrement participant count (min 0).""" + query = ( + meetings.update() + .where(meetings.c.id == meeting_id) + .values( + num_clients=sa.case( + (meetings.c.num_clients > 0, meetings.c.num_clients - 1), else_=0 + ) + ) + ) + await get_database().execute(query) + class MeetingConsentController: async def get_by_meeting_id(self, meeting_id: str) -> list[MeetingConsent]: @@ -214,10 +300,9 @@ class MeetingConsentController: result = await get_database().fetch_one(query) if result is None: return None - return MeetingConsent(**result) if result else None + return MeetingConsent(**result) async def upsert(self, consent: MeetingConsent) -> MeetingConsent: - """Create new consent or update existing one for authenticated users""" if consent.user_id: # For authenticated users, check if consent already exists # not transactional but we're ok with that; the consents ain't deleted anyways diff --git a/server/reflector/db/recordings.py b/server/reflector/db/recordings.py index 0d05790d..bde4afa5 100644 --- a/server/reflector/db/recordings.py +++ b/server/reflector/db/recordings.py @@ -21,6 +21,7 @@ recordings = sa.Table( server_default="pending", ), sa.Column("meeting_id", sa.String), + sa.Column("track_keys", sa.JSON, nullable=True), sa.Index("idx_recording_meeting_id", "meeting_id"), ) @@ -28,10 +29,13 @@ recordings = sa.Table( class Recording(BaseModel): id: str = Field(default_factory=generate_uuid4) bucket_name: str + # for single-track object_key: str recorded_at: datetime status: Literal["pending", "processing", "completed", "failed"] = "pending" meeting_id: str | None = None + # for multitrack reprocessing + track_keys: list[str] | None = None class RecordingController: diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index a38e6b7f..1081ac38 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -1,3 +1,4 @@ +import secrets from datetime import datetime, timezone from sqlite3 import IntegrityError from typing import Literal @@ -8,6 +9,7 @@ from pydantic import BaseModel, Field from sqlalchemy.sql import false, or_ from reflector.db import get_database, metadata +from reflector.schemas.platform import Platform from reflector.utils import generate_uuid4 rooms = sqlalchemy.Table( @@ -40,7 +42,23 @@ rooms = sqlalchemy.Table( sqlalchemy.Column( "is_shared", sqlalchemy.Boolean, nullable=False, server_default=false() ), + sqlalchemy.Column("webhook_url", sqlalchemy.String, nullable=True), + sqlalchemy.Column("webhook_secret", sqlalchemy.String, nullable=True), + sqlalchemy.Column("ics_url", sqlalchemy.Text), + sqlalchemy.Column("ics_fetch_interval", sqlalchemy.Integer, server_default="300"), + sqlalchemy.Column( + "ics_enabled", sqlalchemy.Boolean, nullable=False, server_default=false() + ), + sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)), + sqlalchemy.Column("ics_last_etag", sqlalchemy.Text), + sqlalchemy.Column( + "platform", + sqlalchemy.String, + nullable=True, + server_default=None, + ), sqlalchemy.Index("idx_room_is_shared", "is_shared"), + sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"), ) @@ -55,10 +73,18 @@ class Room(BaseModel): is_locked: bool = False room_mode: Literal["normal", "group"] = "normal" recording_type: Literal["none", "local", "cloud"] = "cloud" - recording_trigger: Literal[ + recording_trigger: Literal[ # whereby-specific "none", "prompt", "automatic", "automatic-2nd-participant" ] = "automatic-2nd-participant" is_shared: bool = False + webhook_url: str | None = None + webhook_secret: str | None = None + ics_url: str | None = None + ics_fetch_interval: int = 300 + ics_enabled: bool = False + ics_last_sync: datetime | None = None + ics_last_etag: str | None = None + platform: Platform | None = None class RoomController: @@ -107,10 +133,19 @@ class RoomController: recording_type: str, recording_trigger: str, is_shared: bool, + webhook_url: str = "", + webhook_secret: str = "", + ics_url: str | None = None, + ics_fetch_interval: int = 300, + ics_enabled: bool = False, + platform: Platform | None = None, ): """ Add a new room """ + if webhook_url and not webhook_secret: + webhook_secret = secrets.token_urlsafe(32) + room = Room( name=name, user_id=user_id, @@ -122,6 +157,12 @@ class RoomController: recording_type=recording_type, recording_trigger=recording_trigger, is_shared=is_shared, + webhook_url=webhook_url, + webhook_secret=webhook_secret, + ics_url=ics_url, + ics_fetch_interval=ics_fetch_interval, + ics_enabled=ics_enabled, + platform=platform, ) query = rooms.insert().values(**room.model_dump()) try: @@ -134,6 +175,9 @@ class RoomController: """ Update a room fields with key/values in values """ + if values.get("webhook_url") and not values.get("webhook_secret"): + values["webhook_secret"] = secrets.token_urlsafe(32) + query = rooms.update().where(rooms.c.id == room.id).values(**values) try: await get_database().execute(query) @@ -183,6 +227,13 @@ class RoomController: return room + async def get_ics_enabled(self) -> list[Room]: + query = rooms.select().where( + rooms.c.ics_enabled == True, rooms.c.ics_url != None + ) + results = await get_database().fetch_all(query) + return [Room(**result) for result in results] + async def remove_by_id( self, room_id: str, diff --git a/server/reflector/db/search.py b/server/reflector/db/search.py index 8ac25212..5d9bc507 100644 --- a/server/reflector/db/search.py +++ b/server/reflector/db/search.py @@ -8,12 +8,14 @@ from typing import Annotated, Any, Dict, Iterator import sqlalchemy import webvtt +from databases.interfaces import Record as DbRecord from fastapi import HTTPException from pydantic import ( BaseModel, Field, NonNegativeFloat, NonNegativeInt, + TypeAdapter, ValidationError, constr, field_serializer, @@ -21,9 +23,10 @@ from pydantic import ( from reflector.db import get_database from reflector.db.rooms import rooms -from reflector.db.transcripts import SourceKind, transcripts +from reflector.db.transcripts import SourceKind, TranscriptStatus, transcripts from reflector.db.utils import is_postgresql from reflector.logger import logger +from reflector.utils.string import NonEmptyString, try_parse_non_empty_string DEFAULT_SEARCH_LIMIT = 20 SNIPPET_CONTEXT_LENGTH = 50 # Characters before/after match to include @@ -31,12 +34,13 @@ DEFAULT_SNIPPET_MAX_LENGTH = NonNegativeInt(150) DEFAULT_MAX_SNIPPETS = NonNegativeInt(3) LONG_SUMMARY_MAX_SNIPPETS = 2 -SearchQueryBase = constr(min_length=0, strip_whitespace=True) +SearchQueryBase = constr(min_length=1, strip_whitespace=True) SearchLimitBase = Annotated[int, Field(ge=1, le=100)] SearchOffsetBase = Annotated[int, Field(ge=0)] SearchTotalBase = Annotated[int, Field(ge=0)] SearchQuery = Annotated[SearchQueryBase, Field(description="Search query text")] +search_query_adapter = TypeAdapter(SearchQuery) SearchLimit = Annotated[SearchLimitBase, Field(description="Results per page")] SearchOffset = Annotated[ SearchOffsetBase, Field(description="Number of results to skip") @@ -88,7 +92,7 @@ class WebVTTProcessor: @staticmethod def generate_snippets( webvtt_content: WebVTTContent, - query: str, + query: SearchQuery, max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS, ) -> list[str]: """Generate snippets from WebVTT content.""" @@ -125,12 +129,14 @@ class SnippetCandidate: class SearchParameters(BaseModel): """Validated search parameters for full-text search.""" - query_text: SearchQuery + query_text: SearchQuery | None = None limit: SearchLimit = DEFAULT_SEARCH_LIMIT offset: SearchOffset = 0 user_id: str | None = None room_id: str | None = None source_kind: SourceKind | None = None + from_datetime: datetime | None = None + to_datetime: datetime | None = None class SearchResultDB(BaseModel): @@ -157,7 +163,7 @@ class SearchResult(BaseModel): room_name: str | None = None source_kind: SourceKind created_at: datetime - status: str = Field(..., min_length=1) + status: TranscriptStatus = Field(..., min_length=1) rank: float = Field(..., ge=0, le=1) duration: NonNegativeFloat | None = Field(..., description="Duration in seconds") search_snippets: list[str] = Field( @@ -199,15 +205,13 @@ class SnippetGenerator: prev_start = start @staticmethod - def count_matches(text: str, query: str) -> NonNegativeInt: + def count_matches(text: str, query: SearchQuery) -> NonNegativeInt: """Count total number of matches for a query in text.""" ZERO = NonNegativeInt(0) if not text: logger.warning("Empty text for search query in count_matches") return ZERO - if not query: - logger.warning("Empty query for search text in count_matches") - return ZERO + assert query is not None return NonNegativeInt( sum(1 for _ in SnippetGenerator.find_all_matches(text, query)) ) @@ -243,13 +247,14 @@ class SnippetGenerator: @staticmethod def generate( text: str, - query: str, + query: SearchQuery, max_length: NonNegativeInt = DEFAULT_SNIPPET_MAX_LENGTH, max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS, ) -> list[str]: """Generate snippets from text.""" - if not text or not query: - logger.warning("Empty text or query for generate_snippets") + assert query is not None + if not text: + logger.warning("Empty text for generate_snippets") return [] candidates = ( @@ -270,7 +275,7 @@ class SnippetGenerator: @staticmethod def from_summary( summary: str, - query: str, + query: SearchQuery, max_snippets: NonNegativeInt = LONG_SUMMARY_MAX_SNIPPETS, ) -> list[str]: """Generate snippets from summary text.""" @@ -278,9 +283,9 @@ class SnippetGenerator: @staticmethod def combine_sources( - summary: str | None, + summary: NonEmptyString | None, webvtt: WebVTTContent | None, - query: str, + query: SearchQuery, max_total: NonNegativeInt = DEFAULT_MAX_SNIPPETS, ) -> tuple[list[str], NonNegativeInt]: """Combine snippets from multiple sources and return total match count. @@ -289,6 +294,11 @@ class SnippetGenerator: snippets can be empty for real in case of e.g. title match """ + + assert ( + summary is not None or webvtt is not None + ), "At least one source must be present" + webvtt_matches = 0 summary_matches = 0 @@ -355,8 +365,8 @@ class SearchController: else_=rooms.c.name, ).label("room_name"), ] - - if params.query_text: + search_query = None + if params.query_text is not None: search_query = sqlalchemy.func.websearch_to_tsquery( "english", params.query_text ) @@ -373,7 +383,9 @@ class SearchController: transcripts.join(rooms, transcripts.c.room_id == rooms.c.id, isouter=True) ) - if params.query_text: + if params.query_text is not None: + # because already initialized based on params.query_text presence above + assert search_query is not None base_query = base_query.where( transcripts.c.search_vector_en.op("@@")(search_query) ) @@ -392,8 +404,16 @@ class SearchController: base_query = base_query.where( transcripts.c.source_kind == params.source_kind ) + if params.from_datetime: + base_query = base_query.where( + transcripts.c.created_at >= params.from_datetime + ) + if params.to_datetime: + base_query = base_query.where( + transcripts.c.created_at <= params.to_datetime + ) - if params.query_text: + if params.query_text is not None: order_by = sqlalchemy.desc(sqlalchemy.text("rank")) else: order_by = sqlalchemy.desc(transcripts.c.created_at) @@ -407,19 +427,29 @@ class SearchController: ) total = await get_database().fetch_val(count_query) - def _process_result(r) -> SearchResult: + def _process_result(r: DbRecord) -> SearchResult: r_dict: Dict[str, Any] = dict(r) + webvtt_raw: str | None = r_dict.pop("webvtt", None) + webvtt: WebVTTContent | None if webvtt_raw: webvtt = WebVTTProcessor.parse(webvtt_raw) else: webvtt = None - long_summary: str | None = r_dict.pop("long_summary", None) + + long_summary_r: str | None = r_dict.pop("long_summary", None) + long_summary: NonEmptyString = try_parse_non_empty_string(long_summary_r) room_name: str | None = r_dict.pop("room_name", None) db_result = SearchResultDB.model_validate(r_dict) - snippets, total_match_count = SnippetGenerator.combine_sources( - long_summary, webvtt, params.query_text, DEFAULT_MAX_SNIPPETS + at_least_one_source = webvtt is not None or long_summary is not None + has_query = params.query_text is not None + snippets, total_match_count = ( + SnippetGenerator.combine_sources( + long_summary, webvtt, params.query_text, DEFAULT_MAX_SNIPPETS + ) + if has_query and at_least_one_source + else ([], 0) ) return SearchResult( diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index 9dbcba9f..f9c3c057 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -21,7 +21,7 @@ from reflector.db.utils import is_postgresql from reflector.logger import logger from reflector.processors.types import Word as ProcessorWord from reflector.settings import settings -from reflector.storage import get_recordings_storage, get_transcripts_storage +from reflector.storage import get_transcripts_storage from reflector.utils import generate_uuid4 from reflector.utils.webvtt import topics_to_webvtt @@ -122,6 +122,15 @@ def generate_transcript_name() -> str: return f"Transcript {now.strftime('%Y-%m-%d %H:%M:%S')}" +TranscriptStatus = Literal[ + "idle", "uploaded", "recording", "processing", "error", "ended" +] + + +class StrValue(BaseModel): + value: str + + class AudioWaveform(BaseModel): data: list[float] @@ -177,6 +186,7 @@ class TranscriptParticipant(BaseModel): id: str = Field(default_factory=generate_uuid4) speaker: int | None name: str + user_id: str | None = None class Transcript(BaseModel): @@ -185,7 +195,7 @@ class Transcript(BaseModel): id: str = Field(default_factory=generate_uuid4) user_id: str | None = None name: str = Field(default_factory=generate_transcript_name) - status: str = "idle" + status: TranscriptStatus = "idle" duration: float = 0 created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) title: str | None = None @@ -614,7 +624,9 @@ class TranscriptController: ) if recording: try: - await get_recordings_storage().delete_file(recording.object_key) + await get_transcripts_storage().delete_file( + recording.object_key, bucket=recording.bucket_name + ) except Exception as e: logger.warning( "Failed to delete recording object from S3", @@ -638,6 +650,19 @@ class TranscriptController: query = transcripts.delete().where(transcripts.c.recording_id == recording_id) await get_database().execute(query) + @staticmethod + def user_can_mutate(transcript: Transcript, user_id: str | None) -> bool: + """ + Returns True if the given user is allowed to modify the transcript. + + Policy: + - Anonymous transcripts (user_id is None) cannot be modified via API + - Only the owner (matching user_id) can modify their transcript + """ + if transcript.user_id is None: + return False + return user_id and transcript.user_id == user_id + @asynccontextmanager async def transaction(self): """ @@ -703,11 +728,13 @@ class TranscriptController: """ Download audio from storage """ - transcript.audio_mp3_filename.write_bytes( - await get_transcripts_storage().get_file( - transcript.storage_audio_path, - ) - ) + storage = get_transcripts_storage() + try: + with open(transcript.audio_mp3_filename, "wb") as f: + await storage.stream_to_fileobj(transcript.storage_audio_path, f) + except Exception: + transcript.audio_mp3_filename.unlink(missing_ok=True) + raise async def upsert_participant( self, @@ -732,5 +759,27 @@ class TranscriptController: transcript.delete_participant(participant_id) await self.update(transcript, {"participants": transcript.participants_dump()}) + async def set_status( + self, transcript_id: str, status: TranscriptStatus + ) -> TranscriptEvent | None: + """ + Update the status of a transcript + + Will add an event STATUS + update the status field of transcript + """ + async with self.transaction(): + transcript = await self.get_by_id(transcript_id) + if not transcript: + raise Exception(f"Transcript {transcript_id} not found") + if transcript.status == status: + return + resp = await self.append_event( + transcript=transcript, + event="STATUS", + data=StrValue(value=status), + ) + await self.update(transcript, {"status": status}) + return resp + transcripts_controller = TranscriptController() diff --git a/server/reflector/db/user_api_keys.py b/server/reflector/db/user_api_keys.py new file mode 100644 index 00000000..8e0ab928 --- /dev/null +++ b/server/reflector/db/user_api_keys.py @@ -0,0 +1,91 @@ +import hmac +import secrets +from datetime import datetime, timezone +from hashlib import sha256 + +import sqlalchemy +from pydantic import BaseModel, Field + +from reflector.db import get_database, metadata +from reflector.settings import settings +from reflector.utils import generate_uuid4 +from reflector.utils.string import NonEmptyString + +user_api_keys = sqlalchemy.Table( + "user_api_key", + metadata, + sqlalchemy.Column("id", sqlalchemy.String, primary_key=True), + sqlalchemy.Column("user_id", sqlalchemy.String, nullable=False), + sqlalchemy.Column("key_hash", sqlalchemy.String, nullable=False), + sqlalchemy.Column("name", sqlalchemy.String, nullable=True), + sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False), + sqlalchemy.Index("idx_user_api_key_hash", "key_hash", unique=True), + sqlalchemy.Index("idx_user_api_key_user_id", "user_id"), +) + + +class UserApiKey(BaseModel): + id: NonEmptyString = Field(default_factory=generate_uuid4) + user_id: NonEmptyString + key_hash: NonEmptyString + name: NonEmptyString | None = None + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class UserApiKeyController: + @staticmethod + def generate_key() -> NonEmptyString: + return secrets.token_urlsafe(48) + + @staticmethod + def hash_key(key: NonEmptyString) -> str: + return hmac.new( + settings.SECRET_KEY.encode(), key.encode(), digestmod=sha256 + ).hexdigest() + + @classmethod + async def create_key( + cls, + user_id: NonEmptyString, + name: NonEmptyString | None = None, + ) -> tuple[UserApiKey, NonEmptyString]: + plaintext = cls.generate_key() + api_key = UserApiKey( + user_id=user_id, + key_hash=cls.hash_key(plaintext), + name=name, + ) + query = user_api_keys.insert().values(**api_key.model_dump()) + await get_database().execute(query) + return api_key, plaintext + + @classmethod + async def verify_key(cls, plaintext_key: NonEmptyString) -> UserApiKey | None: + key_hash = cls.hash_key(plaintext_key) + query = user_api_keys.select().where( + user_api_keys.c.key_hash == key_hash, + ) + result = await get_database().fetch_one(query) + return UserApiKey(**result) if result else None + + @staticmethod + async def list_by_user_id(user_id: NonEmptyString) -> list[UserApiKey]: + query = ( + user_api_keys.select() + .where(user_api_keys.c.user_id == user_id) + .order_by(user_api_keys.c.created_at.desc()) + ) + results = await get_database().fetch_all(query) + return [UserApiKey(**r) for r in results] + + @staticmethod + async def delete_key(key_id: NonEmptyString, user_id: NonEmptyString) -> bool: + query = user_api_keys.delete().where( + (user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id) + ) + result = await get_database().execute(query) + # asyncpg returns None for DELETE, consider it success if no exception + return result is None or result > 0 + + +user_api_keys_controller = UserApiKeyController() diff --git a/server/reflector/pipelines/__init__.py b/server/reflector/pipelines/__init__.py new file mode 100644 index 00000000..89d3e9de --- /dev/null +++ b/server/reflector/pipelines/__init__.py @@ -0,0 +1 @@ +"""Pipeline modules for audio processing.""" diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py index f2c8fb85..6f8e8011 100644 --- a/server/reflector/pipelines/main_file_pipeline.py +++ b/server/reflector/pipelines/main_file_pipeline.py @@ -7,29 +7,34 @@ Uses parallel processing for transcription, diarization, and waveform generation """ import asyncio +import uuid from pathlib import Path import av import structlog -from celery import shared_task +from celery import chain, shared_task +from reflector.asynctask import asynctask +from reflector.db.rooms import rooms_controller from reflector.db.transcripts import ( + SourceKind, Transcript, + TranscriptStatus, transcripts_controller, ) from reflector.logger import logger -from reflector.pipelines.main_live_pipeline import PipelineMainBase, asynctask -from reflector.processors import ( - AudioFileWriterProcessor, - TranscriptFinalSummaryProcessor, - TranscriptFinalTitleProcessor, - TranscriptTopicDetectorProcessor, +from reflector.pipelines import topic_processing +from reflector.pipelines.main_live_pipeline import ( + PipelineMainBase, + broadcast_to_sockets, + task_cleanup_consent, + task_pipeline_post_to_zulip, ) +from reflector.pipelines.transcription_helpers import transcribe_file_with_processor +from reflector.processors import AudioFileWriterProcessor from reflector.processors.audio_waveform_processor import AudioWaveformProcessor from reflector.processors.file_diarization import FileDiarizationInput from reflector.processors.file_diarization_auto import FileDiarizationAutoProcessor -from reflector.processors.file_transcript import FileTranscriptInput -from reflector.processors.file_transcript_auto import FileTranscriptAutoProcessor from reflector.processors.transcript_diarization_assembler import ( TranscriptDiarizationAssemblerInput, TranscriptDiarizationAssemblerProcessor, @@ -43,19 +48,7 @@ from reflector.processors.types import ( ) from reflector.settings import settings from reflector.storage import get_transcripts_storage - - -class EmptyPipeline: - """Empty pipeline for processors that need a pipeline reference""" - - def __init__(self, logger: structlog.BoundLogger): - self.logger = logger - - def get_pref(self, k, d=None): - return d - - async def emit(self, event): - pass +from reflector.worker.webhook import send_transcript_webhook class PipelineMainFile(PipelineMainBase): @@ -70,7 +63,7 @@ class PipelineMainFile(PipelineMainBase): def __init__(self, transcript_id: str): super().__init__(transcript_id=transcript_id) self.logger = logger.bind(transcript_id=self.transcript_id) - self.empty_pipeline = EmptyPipeline(logger=self.logger) + self.empty_pipeline = topic_processing.EmptyPipeline(logger=self.logger) def _handle_gather_exceptions(self, results: list, operation: str) -> None: """Handle exceptions from asyncio.gather with return_exceptions=True""" @@ -83,12 +76,27 @@ class PipelineMainFile(PipelineMainBase): exc_info=result, ) + @broadcast_to_sockets + async def set_status(self, transcript_id: str, status: TranscriptStatus): + async with self.lock_transaction(): + return await transcripts_controller.set_status(transcript_id, status) + async def process(self, file_path: Path): """Main entry point for file processing""" self.logger.info(f"Starting file pipeline for {file_path}") transcript = await self.get_transcript() + # Clear transcript as we're going to regenerate everything + async with self.transaction(): + await transcripts_controller.update( + transcript, + { + "events": [], + "topics": [], + }, + ) + # Extract audio and write to transcript location audio_path = await self.extract_and_write_audio(file_path, transcript) @@ -105,6 +113,8 @@ class PipelineMainFile(PipelineMainBase): self.logger.info("File pipeline complete") + await self.set_status(transcript.id, "ended") + async def extract_and_write_audio( self, file_path: Path, transcript: Transcript ) -> Path: @@ -234,24 +244,7 @@ class PipelineMainFile(PipelineMainBase): async def transcribe_file(self, audio_url: str, language: str) -> TranscriptType: """Transcribe complete file""" - processor = FileTranscriptAutoProcessor() - input_data = FileTranscriptInput(audio_url=audio_url, language=language) - - # Store result for retrieval - result: TranscriptType | None = None - - async def capture_result(transcript): - nonlocal result - result = transcript - - processor.on(capture_result) - await processor.push(input_data) - await processor.flush() - - if not result: - raise ValueError("No transcript captured") - - return result + return await transcribe_file_with_processor(audio_url, language) async def diarize_file(self, audio_url: str) -> list[DiarizationSegment] | None: """Get diarization for file""" @@ -294,63 +287,53 @@ class PipelineMainFile(PipelineMainBase): async def detect_topics( self, transcript: TranscriptType, target_language: str ) -> list[TitleSummary]: - """Detect topics from complete transcript""" - chunk_size = 300 - topics: list[TitleSummary] = [] - - async def on_topic(topic: TitleSummary): - topics.append(topic) - return await self.on_topic(topic) - - topic_detector = TranscriptTopicDetectorProcessor(callback=on_topic) - topic_detector.set_pipeline(self.empty_pipeline) - - for i in range(0, len(transcript.words), chunk_size): - chunk_words = transcript.words[i : i + chunk_size] - if not chunk_words: - continue - - chunk_transcript = TranscriptType( - words=chunk_words, translation=transcript.translation - ) - - await topic_detector.push(chunk_transcript) - - await topic_detector.flush() - return topics + return await topic_processing.detect_topics( + transcript, + target_language, + on_topic_callback=self.on_topic, + empty_pipeline=self.empty_pipeline, + ) async def generate_title(self, topics: list[TitleSummary]): - """Generate title from topics""" - if not topics: - self.logger.warning("No topics for title generation") - return - - processor = TranscriptFinalTitleProcessor(callback=self.on_title) - processor.set_pipeline(self.empty_pipeline) - - for topic in topics: - await processor.push(topic) - - await processor.flush() + return await topic_processing.generate_title( + topics, + on_title_callback=self.on_title, + empty_pipeline=self.empty_pipeline, + logger=self.logger, + ) async def generate_summaries(self, topics: list[TitleSummary]): - """Generate long and short summaries from topics""" - if not topics: - self.logger.warning("No topics for summary generation") - return - transcript = await self.get_transcript() - processor = TranscriptFinalSummaryProcessor( - transcript=transcript, - callback=self.on_long_summary, - on_short_summary=self.on_short_summary, + return await topic_processing.generate_summaries( + topics, + transcript, + on_long_summary_callback=self.on_long_summary, + on_short_summary_callback=self.on_short_summary, + empty_pipeline=self.empty_pipeline, + logger=self.logger, ) - processor.set_pipeline(self.empty_pipeline) - for topic in topics: - await processor.push(topic) - await processor.flush() +@shared_task +@asynctask +async def task_send_webhook_if_needed(*, transcript_id: str): + """Send webhook if this is a room recording with webhook configured""" + transcript = await transcripts_controller.get_by_id(transcript_id) + if not transcript: + return + + if transcript.source_kind == SourceKind.ROOM and transcript.room_id: + room = await rooms_controller.get_by_id(transcript.room_id) + if room and room.webhook_url: + logger.info( + "Dispatching webhook", + transcript_id=transcript_id, + room_id=room.id, + webhook_url=room.webhook_url, + ) + send_transcript_webhook.delay( + transcript_id, room.id, event_id=uuid.uuid4().hex + ) @shared_task @@ -362,14 +345,33 @@ async def task_pipeline_file_process(*, transcript_id: str): if not transcript: raise Exception(f"Transcript {transcript_id} not found") - # Find the file to process - audio_file = next(transcript.data_path.glob("upload.*"), None) - if not audio_file: - audio_file = next(transcript.data_path.glob("audio.*"), None) - - if not audio_file: - raise Exception("No audio file found to process") - - # Run file pipeline pipeline = PipelineMainFile(transcript_id=transcript_id) - await pipeline.process(audio_file) + try: + await pipeline.set_status(transcript_id, "processing") + + # Find the file to process + audio_file = next(transcript.data_path.glob("upload.*"), None) + if not audio_file: + audio_file = next(transcript.data_path.glob("audio.*"), None) + + if not audio_file: + raise Exception("No audio file found to process") + + await pipeline.process(audio_file) + + except Exception as e: + logger.error( + f"File pipeline failed for transcript {transcript_id}: {type(e).__name__}: {str(e)}", + exc_info=True, + transcript_id=transcript_id, + ) + await pipeline.set_status(transcript_id, "error") + raise + + # Run post-processing chain: consent cleanup -> zulip -> webhook + post_chain = chain( + task_cleanup_consent.si(transcript_id=transcript_id), + task_pipeline_post_to_zulip.si(transcript_id=transcript_id), + task_send_webhook_if_needed.si(transcript_id=transcript_id), + ) + post_chain.delay() diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py index b15fcb05..83e560d6 100644 --- a/server/reflector/pipelines/main_live_pipeline.py +++ b/server/reflector/pipelines/main_live_pipeline.py @@ -17,12 +17,11 @@ from contextlib import asynccontextmanager from typing import Generic import av -import boto3 from celery import chord, current_task, group, shared_task from pydantic import BaseModel from structlog import BoundLogger as Logger -from reflector.db import get_database +from reflector.asynctask import asynctask from reflector.db.meetings import meeting_consent_controller, meetings_controller from reflector.db.recordings import recordings_controller from reflector.db.rooms import rooms_controller @@ -32,6 +31,7 @@ from reflector.db.transcripts import ( TranscriptFinalLongSummary, TranscriptFinalShortSummary, TranscriptFinalTitle, + TranscriptStatus, TranscriptText, TranscriptTopic, TranscriptWaveform, @@ -69,29 +69,6 @@ from reflector.zulip import ( ) -def asynctask(f): - @functools.wraps(f) - def wrapper(*args, **kwargs): - async def run_with_db(): - database = get_database() - await database.connect() - try: - return await f(*args, **kwargs) - finally: - await database.disconnect() - - coro = run_with_db() - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - if loop and loop.is_running(): - return loop.run_until_complete(coro) - return asyncio.run(coro) - - return wrapper - - def broadcast_to_sockets(func): """ Decorator to broadcast transcript event to websockets @@ -107,6 +84,20 @@ def broadcast_to_sockets(func): message=resp.model_dump(mode="json"), ) + transcript = await transcripts_controller.get_by_id(self.transcript_id) + if transcript and transcript.user_id: + # Emit only relevant events to the user room to avoid noisy updates. + # Allowed: STATUS, FINAL_TITLE, DURATION. All are prefixed with TRANSCRIPT_ + allowed_user_events = {"STATUS", "FINAL_TITLE", "DURATION"} + if resp.event in allowed_user_events: + await self.ws_manager.send_json( + room_id=f"user:{transcript.user_id}", + message={ + "event": f"TRANSCRIPT_{resp.event}", + "data": {"id": self.transcript_id, **resp.data}, + }, + ) + return wrapper @@ -188,8 +179,15 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage] ] @asynccontextmanager - async def transaction(self): + async def lock_transaction(self): + # This lock is to prevent multiple processor starting adding + # into event array at the same time async with self._lock: + yield + + @asynccontextmanager + async def transaction(self): + async with self.lock_transaction(): async with transcripts_controller.transaction(): yield @@ -198,14 +196,14 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage] # if it's the first part, update the status of the transcript # but do not set the ended status yet. if isinstance(self, PipelineMainLive): - status_mapping = { + status_mapping: dict[str, TranscriptStatus] = { "started": "recording", "push": "recording", "flush": "processing", "error": "error", } elif isinstance(self, PipelineMainFinalSummaries): - status_mapping = { + status_mapping: dict[str, TranscriptStatus] = { "push": "processing", "flush": "processing", "error": "error", @@ -221,22 +219,8 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage] return # when the status of the pipeline changes, update the transcript - async with self.transaction(): - transcript = await self.get_transcript() - if status == transcript.status: - return - resp = await transcripts_controller.append_event( - transcript=transcript, - event="STATUS", - data=StrValue(value=status), - ) - await transcripts_controller.update( - transcript, - { - "status": status, - }, - ) - return resp + async with self._lock: + return await transcripts_controller.set_status(self.transcript_id, status) @broadcast_to_sockets async def on_transcript(self, data): @@ -599,6 +583,7 @@ async def cleanup_consent(transcript: Transcript, logger: Logger): consent_denied = False recording = None + meeting = None try: if transcript.recording_id: recording = await recordings_controller.get_by_id(transcript.recording_id) @@ -609,8 +594,8 @@ async def cleanup_consent(transcript: Transcript, logger: Logger): meeting.id ) except Exception as e: - logger.error(f"Failed to get fetch consent: {e}", exc_info=e) - consent_denied = True + logger.error(f"Failed to fetch consent: {e}", exc_info=e) + raise if not consent_denied: logger.info("Consent approved, keeping all files") @@ -618,25 +603,24 @@ async def cleanup_consent(transcript: Transcript, logger: Logger): logger.info("Consent denied, cleaning up all related audio files") - if recording and recording.bucket_name and recording.object_key: - s3_whereby = boto3.client( - "s3", - aws_access_key_id=settings.AWS_WHEREBY_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_WHEREBY_ACCESS_KEY_SECRET, - ) - try: - s3_whereby.delete_object( - Bucket=recording.bucket_name, Key=recording.object_key - ) - logger.info( - f"Deleted original Whereby recording: {recording.bucket_name}/{recording.object_key}" - ) - except Exception as e: - logger.error(f"Failed to delete Whereby recording: {e}", exc_info=e) + deletion_errors = [] + if recording and recording.bucket_name: + keys_to_delete = [] + if recording.track_keys: + keys_to_delete = recording.track_keys + elif recording.object_key: + keys_to_delete = [recording.object_key] + + master_storage = get_transcripts_storage() + for key in keys_to_delete: + try: + await master_storage.delete_file(key, bucket=recording.bucket_name) + logger.info(f"Deleted recording file: {recording.bucket_name}/{key}") + except Exception as e: + error_msg = f"Failed to delete {key}: {e}" + logger.error(error_msg, exc_info=e) + deletion_errors.append(error_msg) - # non-transactional, files marked for deletion not actually deleted is possible - await transcripts_controller.update(transcript, {"audio_deleted": True}) - # 2. Delete processed audio from transcript storage S3 bucket if transcript.audio_location == "storage": storage = get_transcripts_storage() try: @@ -645,18 +629,28 @@ async def cleanup_consent(transcript: Transcript, logger: Logger): f"Deleted processed audio from storage: {transcript.storage_audio_path}" ) except Exception as e: - logger.error(f"Failed to delete processed audio: {e}", exc_info=e) + error_msg = f"Failed to delete processed audio: {e}" + logger.error(error_msg, exc_info=e) + deletion_errors.append(error_msg) - # 3. Delete local audio files try: if hasattr(transcript, "audio_mp3_filename") and transcript.audio_mp3_filename: transcript.audio_mp3_filename.unlink(missing_ok=True) if hasattr(transcript, "audio_wav_filename") and transcript.audio_wav_filename: transcript.audio_wav_filename.unlink(missing_ok=True) except Exception as e: - logger.error(f"Failed to delete local audio files: {e}", exc_info=e) + error_msg = f"Failed to delete local audio files: {e}" + logger.error(error_msg, exc_info=e) + deletion_errors.append(error_msg) - logger.info("Consent cleanup done") + if deletion_errors: + logger.warning( + f"Consent cleanup completed with {len(deletion_errors)} errors", + errors=deletion_errors, + ) + else: + await transcripts_controller.update(transcript, {"audio_deleted": True}) + logger.info("Consent cleanup done - all audio deleted") @get_transcript @@ -794,7 +788,7 @@ def pipeline_post(*, transcript_id: str): chain_final_summaries, ) | task_pipeline_post_to_zulip.si(transcript_id=transcript_id) - chain.delay() + return chain.delay() @get_transcript diff --git a/server/reflector/pipelines/main_multitrack_pipeline.py b/server/reflector/pipelines/main_multitrack_pipeline.py new file mode 100644 index 00000000..f91c8250 --- /dev/null +++ b/server/reflector/pipelines/main_multitrack_pipeline.py @@ -0,0 +1,695 @@ +import asyncio +import math +import tempfile +from fractions import Fraction +from pathlib import Path + +import av +from av.audio.resampler import AudioResampler +from celery import chain, shared_task + +from reflector.asynctask import asynctask +from reflector.db.transcripts import ( + TranscriptStatus, + TranscriptWaveform, + transcripts_controller, +) +from reflector.logger import logger +from reflector.pipelines import topic_processing +from reflector.pipelines.main_file_pipeline import task_send_webhook_if_needed +from reflector.pipelines.main_live_pipeline import ( + PipelineMainBase, + broadcast_to_sockets, + task_cleanup_consent, + task_pipeline_post_to_zulip, +) +from reflector.pipelines.transcription_helpers import transcribe_file_with_processor +from reflector.processors import AudioFileWriterProcessor +from reflector.processors.audio_waveform_processor import AudioWaveformProcessor +from reflector.processors.types import TitleSummary +from reflector.processors.types import Transcript as TranscriptType +from reflector.storage import Storage, get_transcripts_storage +from reflector.utils.string import NonEmptyString + +# Audio encoding constants +OPUS_STANDARD_SAMPLE_RATE = 48000 +OPUS_DEFAULT_BIT_RATE = 128000 + +# Storage operation constants +PRESIGNED_URL_EXPIRATION_SECONDS = 7200 # 2 hours + + +class PipelineMainMultitrack(PipelineMainBase): + def __init__(self, transcript_id: str): + super().__init__(transcript_id=transcript_id) + self.logger = logger.bind(transcript_id=self.transcript_id) + self.empty_pipeline = topic_processing.EmptyPipeline(logger=self.logger) + + async def pad_track_for_transcription( + self, + track_url: NonEmptyString, + track_idx: int, + storage: Storage, + ) -> NonEmptyString: + """ + Pad a single track with silence based on stream metadata start_time. + Downloads from S3 presigned URL, processes via PyAV using tempfile, uploads to S3. + Returns presigned URL of padded track (or original URL if no padding needed). + + Memory usage: + - Pattern: fixed_overhead(2-5MB) for PyAV codec/filters + - PyAV streams input efficiently (no full download, verified) + - Output written to tempfile (disk-based, not memory) + - Upload streams from file handle (boto3 chunks, typically 5-10MB) + + Daily.co raw-tracks timing - Two approaches: + + CURRENT APPROACH (PyAV metadata): + The WebM stream.start_time field encodes MEETING-RELATIVE timing: + - t=0: When Daily.co recording started (first participant joined) + - start_time=8.13s: This participant's track began 8.13s after recording started + - Purpose: Enables track alignment without external manifest files + + This is NOT: + - Stream-internal offset (first packet timestamp relative to stream start) + - Absolute/wall-clock time + - Recording duration + + ALTERNATIVE APPROACH (filename parsing): + Daily.co filenames contain Unix timestamps (milliseconds): + Format: {recording_start_ts}-{participant_id}-cam-audio-{track_start_ts}.webm + Example: 1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922.webm + + Can calculate offset: (track_start_ts - recording_start_ts) / 1000 + - Track 0: (1760988935922 - 1760988935484) / 1000 = 0.438s + - Track 1: (1760988943823 - 1760988935484) / 1000 = 8.339s + + TIME DIFFERENCE: PyAV metadata vs filename timestamps differ by ~209ms: + - Track 0: filename=438ms, metadata=229ms (diff: 209ms) + - Track 1: filename=8339ms, metadata=8130ms (diff: 209ms) + + Consistent delta suggests network/encoding delay. PyAV metadata is ground truth + (represents when audio stream actually started vs when file upload initiated). + + Example with 2 participants: + Track A: start_time=0.2s → Joined 200ms after recording began + Track B: start_time=8.1s → Joined 8.1 seconds later + + After padding: + Track A: [0.2s silence] + [speech...] + Track B: [8.1s silence] + [speech...] + + Whisper transcription timestamps are now synchronized: + Track A word at 5.0s → happened at meeting t=5.0s + Track B word at 10.0s → happened at meeting t=10.0s + + Merging just sorts by timestamp - no offset calculation needed. + + Padding coincidentally involves re-encoding. It's important when we work with Daily.co + Whisper. + This is because Daily.co returns recordings with skipped frames e.g. when microphone muted. + Daily.co doesn't understand those frames and ignores them, causing timestamp issues in transcription. + Re-encoding restores those frames. We do padding and re-encoding together just because it's convenient and more performant: + we need padded values for mix mp3 anyways + """ + + transcript = await self.get_transcript() + + try: + # PyAV streams input from S3 URL efficiently (2-5MB fixed overhead for codec/filters) + with av.open(track_url) as in_container: + start_time_seconds = self._extract_stream_start_time_from_container( + in_container, track_idx + ) + + if start_time_seconds <= 0: + self.logger.info( + f"Track {track_idx} requires no padding (start_time={start_time_seconds}s)", + track_idx=track_idx, + ) + return track_url + + # Use tempfile instead of BytesIO for better memory efficiency + # Reduces peak memory usage during encoding/upload + with tempfile.NamedTemporaryFile( + suffix=".webm", delete=False + ) as temp_file: + temp_path = temp_file.name + + try: + self._apply_audio_padding_to_file( + in_container, temp_path, start_time_seconds, track_idx + ) + + storage_path = ( + f"file_pipeline/{transcript.id}/tracks/padded_{track_idx}.webm" + ) + + # Upload using file handle for streaming + with open(temp_path, "rb") as padded_file: + await storage.put_file(storage_path, padded_file) + finally: + # Clean up temp file + Path(temp_path).unlink(missing_ok=True) + + padded_url = await storage.get_file_url( + storage_path, + operation="get_object", + expires_in=PRESIGNED_URL_EXPIRATION_SECONDS, + ) + + self.logger.info( + f"Successfully padded track {track_idx}", + track_idx=track_idx, + start_time_seconds=start_time_seconds, + padded_url=padded_url, + ) + + return padded_url + + except Exception as e: + self.logger.error( + f"Failed to process track {track_idx}", + track_idx=track_idx, + url=track_url, + error=str(e), + exc_info=True, + ) + raise Exception( + f"Track {track_idx} padding failed - transcript would have incorrect timestamps" + ) from e + + def _extract_stream_start_time_from_container( + self, container, track_idx: int + ) -> float: + """ + Extract meeting-relative start time from WebM stream metadata. + Uses PyAV to read stream.start_time from WebM container. + More accurate than filename timestamps by ~209ms due to network/encoding delays. + """ + start_time_seconds = 0.0 + try: + audio_streams = [s for s in container.streams if s.type == "audio"] + stream = audio_streams[0] if audio_streams else container.streams[0] + + # 1) Try stream-level start_time (most reliable for Daily.co tracks) + if stream.start_time is not None and stream.time_base is not None: + start_time_seconds = float(stream.start_time * stream.time_base) + + # 2) Fallback to container-level start_time (in av.time_base units) + if (start_time_seconds <= 0) and (container.start_time is not None): + start_time_seconds = float(container.start_time * av.time_base) + + # 3) Fallback to first packet DTS in stream.time_base + if start_time_seconds <= 0: + for packet in container.demux(stream): + if packet.dts is not None: + start_time_seconds = float(packet.dts * stream.time_base) + break + except Exception as e: + self.logger.warning( + "PyAV metadata read failed; assuming 0 start_time", + track_idx=track_idx, + error=str(e), + ) + start_time_seconds = 0.0 + + self.logger.info( + f"Track {track_idx} stream metadata: start_time={start_time_seconds:.3f}s", + track_idx=track_idx, + ) + return start_time_seconds + + def _apply_audio_padding_to_file( + self, + in_container, + output_path: str, + start_time_seconds: float, + track_idx: int, + ) -> None: + """Apply silence padding to audio track using PyAV filter graph, writing to file""" + delay_ms = math.floor(start_time_seconds * 1000) + + self.logger.info( + f"Padding track {track_idx} with {delay_ms}ms delay using PyAV", + track_idx=track_idx, + delay_ms=delay_ms, + ) + + try: + with av.open(output_path, "w", format="webm") as out_container: + in_stream = next( + (s for s in in_container.streams if s.type == "audio"), None + ) + if in_stream is None: + raise Exception("No audio stream in input") + + out_stream = out_container.add_stream( + "libopus", rate=OPUS_STANDARD_SAMPLE_RATE + ) + out_stream.bit_rate = OPUS_DEFAULT_BIT_RATE + graph = av.filter.Graph() + + abuf_args = ( + f"time_base=1/{OPUS_STANDARD_SAMPLE_RATE}:" + f"sample_rate={OPUS_STANDARD_SAMPLE_RATE}:" + f"sample_fmt=s16:" + f"channel_layout=stereo" + ) + src = graph.add("abuffer", args=abuf_args, name="src") + aresample_f = graph.add("aresample", args="async=1", name="ares") + # adelay requires one delay value per channel separated by '|' + delays_arg = f"{delay_ms}|{delay_ms}" + adelay_f = graph.add( + "adelay", args=f"delays={delays_arg}:all=1", name="delay" + ) + sink = graph.add("abuffersink", name="sink") + + src.link_to(aresample_f) + aresample_f.link_to(adelay_f) + adelay_f.link_to(sink) + graph.configure() + + resampler = AudioResampler( + format="s16", layout="stereo", rate=OPUS_STANDARD_SAMPLE_RATE + ) + # Decode -> resample -> push through graph -> encode Opus + for frame in in_container.decode(in_stream): + out_frames = resampler.resample(frame) or [] + for rframe in out_frames: + rframe.sample_rate = OPUS_STANDARD_SAMPLE_RATE + rframe.time_base = Fraction(1, OPUS_STANDARD_SAMPLE_RATE) + src.push(rframe) + + while True: + try: + f_out = sink.pull() + except Exception: + break + f_out.sample_rate = OPUS_STANDARD_SAMPLE_RATE + f_out.time_base = Fraction(1, OPUS_STANDARD_SAMPLE_RATE) + for packet in out_stream.encode(f_out): + out_container.mux(packet) + + src.push(None) + while True: + try: + f_out = sink.pull() + except Exception: + break + f_out.sample_rate = OPUS_STANDARD_SAMPLE_RATE + f_out.time_base = Fraction(1, OPUS_STANDARD_SAMPLE_RATE) + for packet in out_stream.encode(f_out): + out_container.mux(packet) + + for packet in out_stream.encode(None): + out_container.mux(packet) + except Exception as e: + self.logger.error( + "PyAV padding failed for track", + track_idx=track_idx, + delay_ms=delay_ms, + error=str(e), + exc_info=True, + ) + raise + + async def mixdown_tracks( + self, + track_urls: list[str], + writer: AudioFileWriterProcessor, + offsets_seconds: list[float] | None = None, + ) -> None: + """Multi-track mixdown using PyAV filter graph (amix), reading from S3 presigned URLs""" + + target_sample_rate: int | None = None + for url in track_urls: + if not url: + continue + container = None + try: + container = av.open(url) + for frame in container.decode(audio=0): + target_sample_rate = frame.sample_rate + break + except Exception: + continue + finally: + if container is not None: + container.close() + if target_sample_rate: + break + + if not target_sample_rate: + self.logger.error("Mixdown failed - no decodable audio frames found") + raise Exception("Mixdown failed: No decodable audio frames in any track") + # Build PyAV filter graph: + # N abuffer (s32/stereo) + # -> optional adelay per input (for alignment) + # -> amix (s32) + # -> aformat(s16) + # -> sink + graph = av.filter.Graph() + inputs = [] + valid_track_urls = [url for url in track_urls if url] + input_offsets_seconds = None + if offsets_seconds is not None: + input_offsets_seconds = [ + offsets_seconds[i] for i, url in enumerate(track_urls) if url + ] + for idx, url in enumerate(valid_track_urls): + args = ( + f"time_base=1/{target_sample_rate}:" + f"sample_rate={target_sample_rate}:" + f"sample_fmt=s32:" + f"channel_layout=stereo" + ) + in_ctx = graph.add("abuffer", args=args, name=f"in{idx}") + inputs.append(in_ctx) + + if not inputs: + self.logger.error("Mixdown failed - no valid inputs for graph") + raise Exception("Mixdown failed: No valid inputs for filter graph") + + mixer = graph.add("amix", args=f"inputs={len(inputs)}:normalize=0", name="mix") + + fmt = graph.add( + "aformat", + args=( + f"sample_fmts=s32:channel_layouts=stereo:sample_rates={target_sample_rate}" + ), + name="fmt", + ) + + sink = graph.add("abuffersink", name="out") + + # Optional per-input delay before mixing + delays_ms: list[int] = [] + if input_offsets_seconds is not None: + base = min(input_offsets_seconds) if input_offsets_seconds else 0.0 + delays_ms = [ + max(0, int(round((o - base) * 1000))) for o in input_offsets_seconds + ] + else: + delays_ms = [0 for _ in inputs] + + for idx, in_ctx in enumerate(inputs): + delay_ms = delays_ms[idx] if idx < len(delays_ms) else 0 + if delay_ms > 0: + # adelay requires one value per channel; use same for stereo + adelay = graph.add( + "adelay", + args=f"delays={delay_ms}|{delay_ms}:all=1", + name=f"delay{idx}", + ) + in_ctx.link_to(adelay) + adelay.link_to(mixer, 0, idx) + else: + in_ctx.link_to(mixer, 0, idx) + mixer.link_to(fmt) + fmt.link_to(sink) + graph.configure() + + containers = [] + try: + # Open all containers with cleanup guaranteed + for i, url in enumerate(valid_track_urls): + try: + c = av.open(url) + containers.append(c) + except Exception as e: + self.logger.warning( + "Mixdown: failed to open container from URL", + input=i, + url=url, + error=str(e), + ) + + if not containers: + self.logger.error("Mixdown failed - no valid containers opened") + raise Exception("Mixdown failed: Could not open any track containers") + + decoders = [c.decode(audio=0) for c in containers] + active = [True] * len(decoders) + resamplers = [ + AudioResampler(format="s32", layout="stereo", rate=target_sample_rate) + for _ in decoders + ] + + while any(active): + for i, (dec, is_active) in enumerate(zip(decoders, active)): + if not is_active: + continue + try: + frame = next(dec) + except StopIteration: + active[i] = False + continue + + if frame.sample_rate != target_sample_rate: + continue + out_frames = resamplers[i].resample(frame) or [] + for rf in out_frames: + rf.sample_rate = target_sample_rate + rf.time_base = Fraction(1, target_sample_rate) + inputs[i].push(rf) + + while True: + try: + mixed = sink.pull() + except Exception: + break + mixed.sample_rate = target_sample_rate + mixed.time_base = Fraction(1, target_sample_rate) + await writer.push(mixed) + + for in_ctx in inputs: + in_ctx.push(None) + while True: + try: + mixed = sink.pull() + except Exception: + break + mixed.sample_rate = target_sample_rate + mixed.time_base = Fraction(1, target_sample_rate) + await writer.push(mixed) + finally: + # Cleanup all containers, even if processing failed + for c in containers: + if c is not None: + try: + c.close() + except Exception: + pass # Best effort cleanup + + @broadcast_to_sockets + async def set_status(self, transcript_id: str, status: TranscriptStatus): + async with self.lock_transaction(): + return await transcripts_controller.set_status(transcript_id, status) + + async def on_waveform(self, data): + async with self.transaction(): + waveform = TranscriptWaveform(waveform=data) + transcript = await self.get_transcript() + return await transcripts_controller.append_event( + transcript=transcript, event="WAVEFORM", data=waveform + ) + + async def process(self, bucket_name: str, track_keys: list[str]): + transcript = await self.get_transcript() + async with self.transaction(): + await transcripts_controller.update( + transcript, + { + "events": [], + "topics": [], + }, + ) + + source_storage = get_transcripts_storage() + transcript_storage = source_storage + + track_urls: list[str] = [] + for key in track_keys: + url = await source_storage.get_file_url( + key, + operation="get_object", + expires_in=PRESIGNED_URL_EXPIRATION_SECONDS, + bucket=bucket_name, + ) + track_urls.append(url) + self.logger.info( + f"Generated presigned URL for track from {bucket_name}", + key=key, + ) + + created_padded_files = set() + padded_track_urls: list[str] = [] + for idx, url in enumerate(track_urls): + padded_url = await self.pad_track_for_transcription( + url, idx, transcript_storage + ) + padded_track_urls.append(padded_url) + if padded_url != url: + storage_path = f"file_pipeline/{transcript.id}/tracks/padded_{idx}.webm" + created_padded_files.add(storage_path) + self.logger.info(f"Track {idx} processed, padded URL: {padded_url}") + + transcript.data_path.mkdir(parents=True, exist_ok=True) + + mp3_writer = AudioFileWriterProcessor( + path=str(transcript.audio_mp3_filename), + on_duration=self.on_duration, + ) + await self.mixdown_tracks(padded_track_urls, mp3_writer, offsets_seconds=None) + await mp3_writer.flush() + + if not transcript.audio_mp3_filename.exists(): + raise Exception( + "Mixdown failed - no MP3 file generated. Cannot proceed without playable audio." + ) + + storage_path = f"{transcript.id}/audio.mp3" + # Use file handle streaming to avoid loading entire MP3 into memory + mp3_size = transcript.audio_mp3_filename.stat().st_size + with open(transcript.audio_mp3_filename, "rb") as mp3_file: + await transcript_storage.put_file(storage_path, mp3_file) + mp3_url = await transcript_storage.get_file_url(storage_path) + + await transcripts_controller.update(transcript, {"audio_location": "storage"}) + + self.logger.info( + f"Uploaded mixed audio to storage", + storage_path=storage_path, + size=mp3_size, + url=mp3_url, + ) + + self.logger.info("Generating waveform from mixed audio") + waveform_processor = AudioWaveformProcessor( + audio_path=transcript.audio_mp3_filename, + waveform_path=transcript.audio_waveform_filename, + on_waveform=self.on_waveform, + ) + waveform_processor.set_pipeline(self.empty_pipeline) + await waveform_processor.flush() + self.logger.info("Waveform generated successfully") + + speaker_transcripts: list[TranscriptType] = [] + for idx, padded_url in enumerate(padded_track_urls): + if not padded_url: + continue + + t = await self.transcribe_file(padded_url, transcript.source_language) + + if not t.words: + self.logger.debug(f"no words in track {idx}") + # not skipping, it may be silence or indistinguishable mumbling + + for w in t.words: + w.speaker = idx + + speaker_transcripts.append(t) + self.logger.info( + f"Track {idx} transcribed successfully with {len(t.words)} words", + track_idx=idx, + ) + + valid_track_count = len([url for url in padded_track_urls if url]) + if valid_track_count > 0 and len(speaker_transcripts) != valid_track_count: + raise Exception( + f"Only {len(speaker_transcripts)}/{valid_track_count} tracks transcribed successfully. " + f"All tracks must succeed to avoid incomplete transcripts." + ) + + if not speaker_transcripts: + raise Exception("No valid track transcriptions") + + self.logger.info(f"Cleaning up {len(created_padded_files)} temporary S3 files") + cleanup_tasks = [] + for storage_path in created_padded_files: + cleanup_tasks.append(transcript_storage.delete_file(storage_path)) + + if cleanup_tasks: + cleanup_results = await asyncio.gather( + *cleanup_tasks, return_exceptions=True + ) + for storage_path, result in zip(created_padded_files, cleanup_results): + if isinstance(result, Exception): + self.logger.warning( + "Failed to cleanup temporary padded track", + storage_path=storage_path, + error=str(result), + ) + + merged_words = [] + for t in speaker_transcripts: + merged_words.extend(t.words) + merged_words.sort( + key=lambda w: w.start if hasattr(w, "start") and w.start is not None else 0 + ) + + merged_transcript = TranscriptType(words=merged_words, translation=None) + + await self.on_transcript(merged_transcript) + + topics = await self.detect_topics(merged_transcript, transcript.target_language) + await asyncio.gather( + self.generate_title(topics), + self.generate_summaries(topics), + return_exceptions=False, + ) + + await self.set_status(transcript.id, "ended") + + async def transcribe_file(self, audio_url: str, language: str) -> TranscriptType: + return await transcribe_file_with_processor(audio_url, language) + + async def detect_topics( + self, transcript: TranscriptType, target_language: str + ) -> list[TitleSummary]: + return await topic_processing.detect_topics( + transcript, + target_language, + on_topic_callback=self.on_topic, + empty_pipeline=self.empty_pipeline, + ) + + async def generate_title(self, topics: list[TitleSummary]): + return await topic_processing.generate_title( + topics, + on_title_callback=self.on_title, + empty_pipeline=self.empty_pipeline, + logger=self.logger, + ) + + async def generate_summaries(self, topics: list[TitleSummary]): + transcript = await self.get_transcript() + return await topic_processing.generate_summaries( + topics, + transcript, + on_long_summary_callback=self.on_long_summary, + on_short_summary_callback=self.on_short_summary, + empty_pipeline=self.empty_pipeline, + logger=self.logger, + ) + + +@shared_task +@asynctask +async def task_pipeline_multitrack_process( + *, transcript_id: str, bucket_name: str, track_keys: list[str] +): + pipeline = PipelineMainMultitrack(transcript_id=transcript_id) + try: + await pipeline.set_status(transcript_id, "processing") + await pipeline.process(bucket_name, track_keys) + except Exception: + await pipeline.set_status(transcript_id, "error") + raise + + post_chain = chain( + task_cleanup_consent.si(transcript_id=transcript_id), + task_pipeline_post_to_zulip.si(transcript_id=transcript_id), + task_send_webhook_if_needed.si(transcript_id=transcript_id), + ) + post_chain.delay() diff --git a/server/reflector/pipelines/topic_processing.py b/server/reflector/pipelines/topic_processing.py new file mode 100644 index 00000000..7f055025 --- /dev/null +++ b/server/reflector/pipelines/topic_processing.py @@ -0,0 +1,109 @@ +""" +Topic processing utilities +========================== + +Shared topic detection, title generation, and summarization logic +used across file and multitrack pipelines. +""" + +from typing import Callable + +import structlog + +from reflector.db.transcripts import Transcript +from reflector.processors import ( + TranscriptFinalSummaryProcessor, + TranscriptFinalTitleProcessor, + TranscriptTopicDetectorProcessor, +) +from reflector.processors.types import TitleSummary +from reflector.processors.types import Transcript as TranscriptType + + +class EmptyPipeline: + def __init__(self, logger: structlog.BoundLogger): + self.logger = logger + + def get_pref(self, k, d=None): + return d + + async def emit(self, event): + pass + + +async def detect_topics( + transcript: TranscriptType, + target_language: str, + *, + on_topic_callback: Callable, + empty_pipeline: EmptyPipeline, +) -> list[TitleSummary]: + chunk_size = 300 + topics: list[TitleSummary] = [] + + async def on_topic(topic: TitleSummary): + topics.append(topic) + return await on_topic_callback(topic) + + topic_detector = TranscriptTopicDetectorProcessor(callback=on_topic) + topic_detector.set_pipeline(empty_pipeline) + + for i in range(0, len(transcript.words), chunk_size): + chunk_words = transcript.words[i : i + chunk_size] + if not chunk_words: + continue + + chunk_transcript = TranscriptType( + words=chunk_words, translation=transcript.translation + ) + + await topic_detector.push(chunk_transcript) + + await topic_detector.flush() + return topics + + +async def generate_title( + topics: list[TitleSummary], + *, + on_title_callback: Callable, + empty_pipeline: EmptyPipeline, + logger: structlog.BoundLogger, +): + if not topics: + logger.warning("No topics for title generation") + return + + processor = TranscriptFinalTitleProcessor(callback=on_title_callback) + processor.set_pipeline(empty_pipeline) + + for topic in topics: + await processor.push(topic) + + await processor.flush() + + +async def generate_summaries( + topics: list[TitleSummary], + transcript: Transcript, + *, + on_long_summary_callback: Callable, + on_short_summary_callback: Callable, + empty_pipeline: EmptyPipeline, + logger: structlog.BoundLogger, +): + if not topics: + logger.warning("No topics for summary generation") + return + + processor = TranscriptFinalSummaryProcessor( + transcript=transcript, + callback=on_long_summary_callback, + on_short_summary=on_short_summary_callback, + ) + processor.set_pipeline(empty_pipeline) + + for topic in topics: + await processor.push(topic) + + await processor.flush() diff --git a/server/reflector/pipelines/transcription_helpers.py b/server/reflector/pipelines/transcription_helpers.py new file mode 100644 index 00000000..b0cc5858 --- /dev/null +++ b/server/reflector/pipelines/transcription_helpers.py @@ -0,0 +1,34 @@ +from reflector.processors.file_transcript import FileTranscriptInput +from reflector.processors.file_transcript_auto import FileTranscriptAutoProcessor +from reflector.processors.types import Transcript as TranscriptType + + +async def transcribe_file_with_processor( + audio_url: str, + language: str, + processor_name: str | None = None, +) -> TranscriptType: + processor = ( + FileTranscriptAutoProcessor(name=processor_name) + if processor_name + else FileTranscriptAutoProcessor() + ) + input_data = FileTranscriptInput(audio_url=audio_url, language=language) + + result: TranscriptType | None = None + + async def capture_result(transcript): + nonlocal result + result = transcript + + processor.on(capture_result) + await processor.push(input_data) + await processor.flush() + + if not result: + processor_label = processor_name or "default" + raise ValueError( + f"No transcript captured from {processor_label} processor for audio: {audio_url}" + ) + + return result diff --git a/server/reflector/processors/file_diarization_modal.py b/server/reflector/processors/file_diarization_modal.py index 518f444e..8865063d 100644 --- a/server/reflector/processors/file_diarization_modal.py +++ b/server/reflector/processors/file_diarization_modal.py @@ -47,6 +47,7 @@ class FileDiarizationModalProcessor(FileDiarizationProcessor): "audio_file_url": data.audio_url, "timestamp": 0, }, + follow_redirects=True, ) response.raise_for_status() diarization_data = response.json()["diarization"] diff --git a/server/reflector/processors/file_transcript_modal.py b/server/reflector/processors/file_transcript_modal.py index 21c378ec..d29b8eac 100644 --- a/server/reflector/processors/file_transcript_modal.py +++ b/server/reflector/processors/file_transcript_modal.py @@ -54,7 +54,18 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor): "language": data.language, "batch": True, }, + follow_redirects=True, ) + + if response.status_code != 200: + error_body = response.text + self.logger.error( + "Modal API error", + audio_url=data.audio_url, + status_code=response.status_code, + error_body=error_body, + ) + response.raise_for_status() result = response.json() @@ -67,6 +78,9 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor): for word_info in result.get("words", []) ] + # words come not in order + words.sort(key=lambda w: w.start) + return Transcript(words=words) diff --git a/server/reflector/processors/summary/summary_builder.py b/server/reflector/processors/summary/summary_builder.py index efcf9227..df348093 100644 --- a/server/reflector/processors/summary/summary_builder.py +++ b/server/reflector/processors/summary/summary_builder.py @@ -165,6 +165,7 @@ class SummaryBuilder: self.llm: LLM = llm self.model_name: str = llm.model_name self.logger = logger or structlog.get_logger() + self.participant_instructions: str | None = None if filename: self.read_transcript_from_file(filename) @@ -191,14 +192,61 @@ class SummaryBuilder: self, prompt: str, output_cls: Type[T], tone_name: str | None = None ) -> T: """Generic function to get structured output from LLM for non-function-calling models.""" + # Add participant instructions to the prompt if available + enhanced_prompt = self._enhance_prompt_with_participants(prompt) return await self.llm.get_structured_response( - prompt, [self.transcript], output_cls, tone_name=tone_name + enhanced_prompt, [self.transcript], output_cls, tone_name=tone_name ) + async def _get_response( + self, prompt: str, texts: list[str], tone_name: str | None = None + ) -> str: + """Get text response with automatic participant instructions injection.""" + enhanced_prompt = self._enhance_prompt_with_participants(prompt) + return await self.llm.get_response(enhanced_prompt, texts, tone_name=tone_name) + + def _enhance_prompt_with_participants(self, prompt: str) -> str: + """Add participant instructions to any prompt if participants are known.""" + if self.participant_instructions: + self.logger.debug("Adding participant instructions to prompt") + return f"{prompt}\n\n{self.participant_instructions}" + return prompt + # ---------------------------------------------------------------------------- # Participants # ---------------------------------------------------------------------------- + def set_known_participants(self, participants: list[str]) -> None: + """ + Set known participants directly without LLM identification. + This is used when participants are already identified and stored. + They are appended at the end of the transcript, providing more context for the assistant. + """ + if not participants: + self.logger.warning("No participants provided") + return + + self.logger.info( + "Using known participants", + participants=participants, + ) + + participants_md = self.format_list_md(participants) + self.transcript += f"\n\n# Participants\n\n{participants_md}" + + # Set instructions that will be automatically added to all prompts + participants_list = ", ".join(participants) + self.participant_instructions = dedent( + f""" + # IMPORTANT: Participant Names + The following participants are identified in this conversation: {participants_list} + + You MUST use these specific participant names when referring to people in your response. + Do NOT use generic terms like "a participant", "someone", "attendee", "Speaker 1", "Speaker 2", etc. + Always refer to people by their actual names (e.g., "John suggested..." not "A participant suggested..."). + """ + ).strip() + async def identify_participants(self) -> None: """ From a transcript, try to identify the participants using TreeSummarize with structured output. @@ -232,6 +280,19 @@ class SummaryBuilder: if unique_participants: participants_md = self.format_list_md(unique_participants) self.transcript += f"\n\n# Participants\n\n{participants_md}" + + # Set instructions that will be automatically added to all prompts + participants_list = ", ".join(unique_participants) + self.participant_instructions = dedent( + f""" + # IMPORTANT: Participant Names + The following participants are identified in this conversation: {participants_list} + + You MUST use these specific participant names when referring to people in your response. + Do NOT use generic terms like "a participant", "someone", "attendee", "Speaker 1", "Speaker 2", etc. + Always refer to people by their actual names (e.g., "John suggested..." not "A participant suggested..."). + """ + ).strip() else: self.logger.warning("No participants identified in the transcript") @@ -318,13 +379,13 @@ class SummaryBuilder: for subject in self.subjects: detailed_prompt = DETAILED_SUBJECT_PROMPT_TEMPLATE.format(subject=subject) - detailed_response = await self.llm.get_response( + detailed_response = await self._get_response( detailed_prompt, [self.transcript], tone_name="Topic assistant" ) paragraph_prompt = PARAGRAPH_SUMMARY_PROMPT - paragraph_response = await self.llm.get_response( + paragraph_response = await self._get_response( paragraph_prompt, [str(detailed_response)], tone_name="Topic summarizer" ) @@ -345,7 +406,7 @@ class SummaryBuilder: recap_prompt = RECAP_PROMPT - recap_response = await self.llm.get_response( + recap_response = await self._get_response( recap_prompt, [summaries_text], tone_name="Recap summarizer" ) diff --git a/server/reflector/processors/transcript_final_summary.py b/server/reflector/processors/transcript_final_summary.py index 0b4a594c..dfe07aad 100644 --- a/server/reflector/processors/transcript_final_summary.py +++ b/server/reflector/processors/transcript_final_summary.py @@ -26,7 +26,25 @@ class TranscriptFinalSummaryProcessor(Processor): async def get_summary_builder(self, text) -> SummaryBuilder: builder = SummaryBuilder(self.llm, logger=self.logger) builder.set_transcript(text) - await builder.identify_participants() + + # Use known participants if available, otherwise identify them + if self.transcript and self.transcript.participants: + # Extract participant names from the stored participants + participant_names = [p.name for p in self.transcript.participants if p.name] + if participant_names: + self.logger.info( + f"Using {len(participant_names)} known participants from transcript" + ) + builder.set_known_participants(participant_names) + else: + self.logger.info( + "Participants field exists but is empty, identifying participants" + ) + await builder.identify_participants() + else: + self.logger.info("No participants stored, identifying participants") + await builder.identify_participants() + await builder.generate_summary() return builder @@ -49,18 +67,30 @@ class TranscriptFinalSummaryProcessor(Processor): speakermap = {} if self.transcript: speakermap = { - participant["speaker"]: participant["name"] - for participant in self.transcript.participants + p.speaker: p.name + for p in (self.transcript.participants or []) + if p.speaker is not None and p.name } + self.logger.info( + f"Built speaker map with {len(speakermap)} participants", + speakermap=speakermap, + ) # build the transcript as a single string - # XXX: unsure if the participants name as replaced directly in speaker ? + # Replace speaker IDs with actual participant names if available text_transcript = [] + unique_speakers = set() for topic in self.chunks: for segment in topic.transcript.as_segments(): name = speakermap.get(segment.speaker, f"Speaker {segment.speaker}") + unique_speakers.add((segment.speaker, name)) text_transcript.append(f"{name}: {segment.text}") + self.logger.info( + f"Built transcript with {len(unique_speakers)} unique speakers", + speakers=list(unique_speakers), + ) + text_transcript = "\n".join(text_transcript) last_chunk = self.chunks[-1] diff --git a/server/reflector/processors/transcript_topic_detector.py b/server/reflector/processors/transcript_topic_detector.py index e0e306ce..695d3af3 100644 --- a/server/reflector/processors/transcript_topic_detector.py +++ b/server/reflector/processors/transcript_topic_detector.py @@ -1,6 +1,6 @@ from textwrap import dedent -from pydantic import BaseModel, Field +from pydantic import AliasChoices, BaseModel, Field from reflector.llm import LLM from reflector.processors.base import Processor @@ -34,8 +34,14 @@ TOPIC_PROMPT = dedent( class TopicResponse(BaseModel): """Structured response for topic detection""" - title: str = Field(description="A descriptive title for the topic being discussed") - summary: str = Field(description="A concise 1-2 sentence summary of the discussion") + title: str = Field( + description="A descriptive title for the topic being discussed", + validation_alias=AliasChoices("title", "Title"), + ) + summary: str = Field( + description="A concise 1-2 sentence summary of the discussion", + validation_alias=AliasChoices("summary", "Summary"), + ) class TranscriptTopicDetectorProcessor(Processor): diff --git a/server/reflector/processors/types.py b/server/reflector/processors/types.py index 480086af..7096e81c 100644 --- a/server/reflector/processors/types.py +++ b/server/reflector/processors/types.py @@ -4,11 +4,8 @@ import tempfile from pathlib import Path from typing import Annotated, TypedDict -from profanityfilter import ProfanityFilter from pydantic import BaseModel, Field, PrivateAttr -from reflector.redis_cache import redis_cache - class DiarizationSegment(TypedDict): """Type definition for diarization segment containing speaker information""" @@ -20,9 +17,6 @@ class DiarizationSegment(TypedDict): PUNC_RE = re.compile(r"[.;:?!…]") -profanity_filter = ProfanityFilter() -profanity_filter.set_censor("*") - class AudioFile(BaseModel): name: str @@ -124,21 +118,11 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]: class Transcript(BaseModel): translation: str | None = None - words: list[Word] = None - - @property - def raw_text(self): - # Uncensored text - return "".join([word.text for word in self.words]) - - @redis_cache(prefix="profanity", duration=3600 * 24 * 7) - def _get_censored_text(self, text: str): - return profanity_filter.censor(text).strip() + words: list[Word] = [] @property def text(self): - # Censored text - return self._get_censored_text(self.raw_text) + return "".join([word.text for word in self.words]) @property def human_timestamp(self): @@ -170,12 +154,6 @@ class Transcript(BaseModel): word.start += offset word.end += offset - def clone(self): - words = [ - Word(text=word.text, start=word.start, end=word.end) for word in self.words - ] - return Transcript(text=self.text, translation=self.translation, words=words) - def as_segments(self) -> list[TranscriptSegment]: return words_to_segments(self.words) diff --git a/server/reflector/redis_cache.py b/server/reflector/redis_cache.py index 2215149e..cb7ac3b8 100644 --- a/server/reflector/redis_cache.py +++ b/server/reflector/redis_cache.py @@ -1,10 +1,17 @@ +import asyncio import functools import json +from typing import Optional import redis +import redis.asyncio as redis_async +import structlog +from redis.exceptions import LockError from reflector.settings import settings +logger = structlog.get_logger(__name__) + redis_clients = {} @@ -21,6 +28,12 @@ def get_redis_client(db=0): return redis_clients[db] +async def get_async_redis_client(db: int = 0): + return await redis_async.from_url( + f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}/{db}" + ) + + def redis_cache(prefix="cache", duration=3600, db=settings.REDIS_CACHE_DB, argidx=1): """ Cache the result of a function in Redis. @@ -49,3 +62,87 @@ def redis_cache(prefix="cache", duration=3600, db=settings.REDIS_CACHE_DB, argid return wrapper return decorator + + +class RedisAsyncLock: + def __init__( + self, + key: str, + timeout: int = 120, + extend_interval: int = 30, + skip_if_locked: bool = False, + blocking: bool = True, + blocking_timeout: Optional[float] = None, + ): + self.key = f"async_lock:{key}" + self.timeout = timeout + self.extend_interval = extend_interval + self.skip_if_locked = skip_if_locked + self.blocking = blocking + self.blocking_timeout = blocking_timeout + self._lock = None + self._redis = None + self._extend_task = None + self._acquired = False + + async def _extend_lock_periodically(self): + while True: + try: + await asyncio.sleep(self.extend_interval) + if self._lock: + await self._lock.extend(self.timeout, replace_ttl=True) + logger.debug("Extended lock", key=self.key) + except LockError: + logger.warning("Failed to extend lock", key=self.key) + break + except asyncio.CancelledError: + break + except Exception as e: + logger.error("Error extending lock", key=self.key, error=str(e)) + break + + async def __aenter__(self): + self._redis = await get_async_redis_client() + self._lock = self._redis.lock( + self.key, + timeout=self.timeout, + blocking=self.blocking, + blocking_timeout=self.blocking_timeout, + ) + + self._acquired = await self._lock.acquire() + + if not self._acquired: + if self.skip_if_locked: + logger.warning( + "Lock already acquired by another process, skipping", key=self.key + ) + return self + else: + raise LockError(f"Failed to acquire lock: {self.key}") + + self._extend_task = asyncio.create_task(self._extend_lock_periodically()) + logger.info("Acquired lock", key=self.key) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._extend_task: + self._extend_task.cancel() + try: + await self._extend_task + except asyncio.CancelledError: + pass + + if self._acquired and self._lock: + try: + await self._lock.release() + logger.info("Released lock", key=self.key) + except LockError: + logger.debug("Lock already released or expired", key=self.key) + + if self._redis: + await self._redis.aclose() + + @property + def acquired(self) -> bool: + return self._acquired diff --git a/server/reflector/schemas/platform.py b/server/reflector/schemas/platform.py new file mode 100644 index 00000000..7b945841 --- /dev/null +++ b/server/reflector/schemas/platform.py @@ -0,0 +1,5 @@ +from typing import Literal + +Platform = Literal["whereby", "daily"] +WHEREBY_PLATFORM: Platform = "whereby" +DAILY_PLATFORM: Platform = "daily" diff --git a/server/reflector/services/ics_sync.py b/server/reflector/services/ics_sync.py new file mode 100644 index 00000000..2a4855cb --- /dev/null +++ b/server/reflector/services/ics_sync.py @@ -0,0 +1,408 @@ +""" +ICS Calendar Synchronization Service + +This module provides services for fetching, parsing, and synchronizing ICS (iCalendar) +calendar feeds with room booking data in the database. + +Key Components: +- ICSFetchService: Handles HTTP fetching and parsing of ICS calendar data +- ICSSyncService: Manages the synchronization process between ICS feeds and database + +Example Usage: + # Sync a room's calendar + room = Room(id="room1", name="conference-room", ics_url="https://cal.example.com/room.ics") + result = await ics_sync_service.sync_room_calendar(room) + + # Result structure: + { + "status": "success", # success|unchanged|error|skipped + "hash": "abc123...", # MD5 hash of ICS content + "events_found": 5, # Events matching this room + "total_events": 12, # Total events in calendar within time window + "events_created": 2, # New events added to database + "events_updated": 3, # Existing events modified + "events_deleted": 1 # Events soft-deleted (no longer in calendar) + } + +Event Matching: + Events are matched to rooms by checking if the room's full URL appears in the + event's LOCATION or DESCRIPTION fields. Only events within a 25-hour window + (1 hour ago to 24 hours from now) are processed. + +Input: ICS calendar URL (e.g., "https://calendar.google.com/calendar/ical/...") +Output: EventData objects with structured calendar information: + { + "ics_uid": "event123@google.com", + "title": "Team Meeting", + "description": "Weekly sync meeting", + "location": "https://meet.company.com/conference-room", + "start_time": datetime(2024, 1, 15, 14, 0, tzinfo=UTC), + "end_time": datetime(2024, 1, 15, 15, 0, tzinfo=UTC), + "attendees": [ + {"email": "user@company.com", "name": "John Doe", "role": "ORGANIZER"}, + {"email": "attendee@company.com", "name": "Jane Smith", "status": "ACCEPTED"} + ], + "ics_raw_data": "BEGIN:VEVENT\nUID:event123@google.com\n..." + } +""" + +import hashlib +from datetime import date, datetime, timedelta, timezone +from enum import Enum +from typing import TypedDict + +import httpx +import pytz +import structlog +from icalendar import Calendar, Event + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.rooms import Room, rooms_controller +from reflector.redis_cache import RedisAsyncLock +from reflector.settings import settings + +logger = structlog.get_logger() + +EVENT_WINDOW_DELTA_START = timedelta(hours=-1) +EVENT_WINDOW_DELTA_END = timedelta(hours=24) + + +class SyncStatus(str, Enum): + SUCCESS = "success" + UNCHANGED = "unchanged" + ERROR = "error" + SKIPPED = "skipped" + + +class AttendeeData(TypedDict, total=False): + email: str | None + name: str | None + status: str | None + role: str | None + + +class EventData(TypedDict): + ics_uid: str + title: str | None + description: str | None + location: str | None + start_time: datetime + end_time: datetime + attendees: list[AttendeeData] + ics_raw_data: str + + +class SyncStats(TypedDict): + events_created: int + events_updated: int + events_deleted: int + + +class SyncResultBase(TypedDict): + status: SyncStatus + + +class SyncResult(SyncResultBase, total=False): + hash: str | None + events_found: int + total_events: int + events_created: int + events_updated: int + events_deleted: int + error: str | None + reason: str | None + + +class ICSFetchService: + def __init__(self): + self.client = httpx.AsyncClient( + timeout=30.0, headers={"User-Agent": "Reflector/1.0"} + ) + + async def fetch_ics(self, url: str) -> str: + response = await self.client.get(url) + response.raise_for_status() + + return response.text + + def parse_ics(self, ics_content: str) -> Calendar: + return Calendar.from_ical(ics_content) + + def extract_room_events( + self, calendar: Calendar, room_name: str, room_url: str + ) -> tuple[list[EventData], int]: + events = [] + total_events = 0 + now = datetime.now(timezone.utc) + window_start = now + EVENT_WINDOW_DELTA_START + window_end = now + EVENT_WINDOW_DELTA_END + + for component in calendar.walk(): + if component.name != "VEVENT": + continue + + status = component.get("STATUS", "").upper() + if status == "CANCELLED": + continue + + # Count total non-cancelled events in the time window + event_data = self._parse_event(component) + if event_data and window_start <= event_data["start_time"] <= window_end: + total_events += 1 + + # Check if event matches this room + if self._event_matches_room(component, room_name, room_url): + events.append(event_data) + + return events, total_events + + def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool: + location = str(event.get("LOCATION", "")) + description = str(event.get("DESCRIPTION", "")) + + # Only match full room URL + # XXX leaved here as a patterns, to later be extended with tinyurl or such too + patterns = [ + room_url, + ] + + # Check location and description for patterns + text_to_check = f"{location} {description}".lower() + for pattern in patterns: + if pattern.lower() in text_to_check: + return True + + return False + + def _parse_event(self, event: Event) -> EventData | None: + uid = str(event.get("UID", "")) + summary = str(event.get("SUMMARY", "")) + description = str(event.get("DESCRIPTION", "")) + location = str(event.get("LOCATION", "")) + dtstart = event.get("DTSTART") + dtend = event.get("DTEND") + + if not dtstart: + return None + + # Convert fields + start_time = self._normalize_datetime( + dtstart.dt if hasattr(dtstart, "dt") else dtstart + ) + end_time = ( + self._normalize_datetime(dtend.dt if hasattr(dtend, "dt") else dtend) + if dtend + else start_time + timedelta(hours=1) + ) + attendees = self._parse_attendees(event) + + # Get raw event data for storage + raw_data = event.to_ical().decode("utf-8") + + return { + "ics_uid": uid, + "title": summary, + "description": description, + "location": location, + "start_time": start_time, + "end_time": end_time, + "attendees": attendees, + "ics_raw_data": raw_data, + } + + def _normalize_datetime(self, dt) -> datetime: + # Ensure datetime is with timezone, if not, assume UTC + if isinstance(dt, date) and not isinstance(dt, datetime): + dt = datetime.combine(dt, datetime.min.time()) + dt = pytz.UTC.localize(dt) + elif isinstance(dt, datetime): + if dt.tzinfo is None: + dt = pytz.UTC.localize(dt) + else: + dt = dt.astimezone(pytz.UTC) + + return dt + + def _parse_attendees(self, event: Event) -> list[AttendeeData]: + # Extracts attendee information from both ATTENDEE and ORGANIZER properties. + # Handles malformed comma-separated email addresses in single ATTENDEE fields + # by splitting them into separate attendee entries. Returns a list of attendee + # data including email, name, status, and role information. + final_attendees = [] + + attendees = event.get("ATTENDEE", []) + if not isinstance(attendees, list): + attendees = [attendees] + for att in attendees: + email_str = str(att).replace("mailto:", "") if att else None + + # Handle malformed comma-separated email addresses in a single ATTENDEE field + if email_str and "," in email_str: + # Split comma-separated emails and create separate attendee entries + email_parts = [email.strip() for email in email_str.split(",")] + for email in email_parts: + if email and "@" in email: + clean_email = email.replace("MAILTO:", "").replace( + "mailto:", "" + ) + att_data: AttendeeData = { + "email": clean_email, + "name": att.params.get("CN") + if hasattr(att, "params") and email == email_parts[0] + else None, + "status": att.params.get("PARTSTAT") + if hasattr(att, "params") and email == email_parts[0] + else None, + "role": att.params.get("ROLE") + if hasattr(att, "params") and email == email_parts[0] + else None, + } + final_attendees.append(att_data) + else: + # Normal single attendee + att_data: AttendeeData = { + "email": email_str, + "name": att.params.get("CN") if hasattr(att, "params") else None, + "status": att.params.get("PARTSTAT") + if hasattr(att, "params") + else None, + "role": att.params.get("ROLE") if hasattr(att, "params") else None, + } + final_attendees.append(att_data) + + # Add organizer + organizer = event.get("ORGANIZER") + if organizer: + org_email = ( + str(organizer).replace("mailto:", "").replace("MAILTO:", "") + if organizer + else None + ) + org_data: AttendeeData = { + "email": org_email, + "name": organizer.params.get("CN") + if hasattr(organizer, "params") + else None, + "role": "ORGANIZER", + } + final_attendees.append(org_data) + + return final_attendees + + +class ICSSyncService: + def __init__(self): + self.fetch_service = ICSFetchService() + + async def sync_room_calendar(self, room: Room) -> SyncResult: + async with RedisAsyncLock( + f"ics_sync_room:{room.id}", skip_if_locked=True + ) as lock: + if not lock.acquired: + logger.warning("ICS sync already in progress for room", room_id=room.id) + return { + "status": SyncStatus.SKIPPED, + "reason": "Sync already in progress", + } + + return await self._sync_room_calendar(room) + + async def _sync_room_calendar(self, room: Room) -> SyncResult: + if not room.ics_enabled or not room.ics_url: + return {"status": SyncStatus.SKIPPED, "reason": "ICS not configured"} + + try: + if not self._should_sync(room): + return {"status": SyncStatus.SKIPPED, "reason": "Not time to sync yet"} + + ics_content = await self.fetch_service.fetch_ics(room.ics_url) + calendar = self.fetch_service.parse_ics(ics_content) + + content_hash = hashlib.md5(ics_content.encode()).hexdigest() + if room.ics_last_etag == content_hash: + logger.info("No changes in ICS for room", room_id=room.id) + room_url = f"{settings.UI_BASE_URL}/{room.name}" + events, total_events = self.fetch_service.extract_room_events( + calendar, room.name, room_url + ) + return { + "status": SyncStatus.UNCHANGED, + "hash": content_hash, + "events_found": len(events), + "total_events": total_events, + "events_created": 0, + "events_updated": 0, + "events_deleted": 0, + } + + # Extract matching events + room_url = f"{settings.UI_BASE_URL}/{room.name}" + events, total_events = self.fetch_service.extract_room_events( + calendar, room.name, room_url + ) + sync_result = await self._sync_events_to_database(room.id, events) + + # Update room sync metadata + await rooms_controller.update( + room, + { + "ics_last_sync": datetime.now(timezone.utc), + "ics_last_etag": content_hash, + }, + mutate=False, + ) + + return { + "status": SyncStatus.SUCCESS, + "hash": content_hash, + "events_found": len(events), + "total_events": total_events, + **sync_result, + } + + except Exception as e: + logger.error("Failed to sync ICS for room", room_id=room.id, error=str(e)) + return {"status": SyncStatus.ERROR, "error": str(e)} + + def _should_sync(self, room: Room) -> bool: + if not room.ics_last_sync: + return True + + time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync + return time_since_sync.total_seconds() >= room.ics_fetch_interval + + async def _sync_events_to_database( + self, room_id: str, events: list[EventData] + ) -> SyncStats: + created = 0 + updated = 0 + + current_ics_uids = [] + + for event_data in events: + calendar_event = CalendarEvent(room_id=room_id, **event_data) + existing = await calendar_events_controller.get_by_ics_uid( + room_id, event_data["ics_uid"] + ) + + if existing: + updated += 1 + else: + created += 1 + + await calendar_events_controller.upsert(calendar_event) + current_ics_uids.append(event_data["ics_uid"]) + + # Soft delete events that are no longer in calendar + deleted = await calendar_events_controller.soft_delete_missing( + room_id, current_ics_uids + ) + + return { + "events_created": created, + "events_updated": updated, + "events_deleted": deleted, + } + + +ics_sync_service = ICSSyncService() diff --git a/server/reflector/settings.py b/server/reflector/settings.py index bbc835cd..0e3fb3f7 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -1,5 +1,9 @@ +from pydantic.types import PositiveInt from pydantic_settings import BaseSettings, SettingsConfigDict +from reflector.schemas.platform import WHEREBY_PLATFORM, Platform +from reflector.utils.string import NonEmptyString + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -44,14 +48,17 @@ class Settings(BaseSettings): TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None - # Recording storage - RECORDING_STORAGE_BACKEND: str | None = None + # Platform-specific recording storage (follows {PREFIX}_STORAGE_AWS_{CREDENTIAL} pattern) + # Whereby storage configuration + WHEREBY_STORAGE_AWS_BUCKET_NAME: str | None = None + WHEREBY_STORAGE_AWS_REGION: str | None = None + WHEREBY_STORAGE_AWS_ACCESS_KEY_ID: str | None = None + WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None - # Recording storage configuration for AWS - RECORDING_STORAGE_AWS_BUCKET_NAME: str = "recording-bucket" - RECORDING_STORAGE_AWS_REGION: str = "us-east-1" - RECORDING_STORAGE_AWS_ACCESS_KEY_ID: str | None = None - RECORDING_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None + # Daily.co storage configuration + DAILYCO_STORAGE_AWS_BUCKET_NAME: str | None = None + DAILYCO_STORAGE_AWS_REGION: str | None = None + DAILYCO_STORAGE_AWS_ROLE_ARN: str | None = None # Translate into the target language TRANSLATION_BACKEND: str = "passthrough" @@ -90,9 +97,8 @@ class Settings(BaseSettings): AUTH_JWT_PUBLIC_KEY: str | None = "authentik.monadical.com_public.pem" AUTH_JWT_AUDIENCE: str | None = None - # API public mode - # if set, all anonymous record will be public PUBLIC_MODE: bool = False + PUBLIC_DATA_RETENTION_DAYS: PositiveInt = 7 # Min transcript length to generate topic + summary MIN_TRANSCRIPT_LENGTH: int = 750 @@ -120,13 +126,22 @@ class Settings(BaseSettings): # Whereby integration WHEREBY_API_URL: str = "https://api.whereby.dev/v1" - WHEREBY_API_KEY: str | None = None + WHEREBY_API_KEY: NonEmptyString | None = None WHEREBY_WEBHOOK_SECRET: str | None = None - AWS_WHEREBY_ACCESS_KEY_ID: str | None = None - AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None SQS_POLLING_TIMEOUT_SECONDS: int = 60 + # Daily.co integration + DAILY_API_KEY: str | None = None + DAILY_WEBHOOK_SECRET: str | None = None + DAILY_SUBDOMAIN: str | None = None + DAILY_WEBHOOK_UUID: str | None = ( + None # Webhook UUID for this environment. Not used by production code + ) + + # Platform Configuration + DEFAULT_VIDEO_PLATFORM: Platform = WHEREBY_PLATFORM + # Zulip integration ZULIP_REALM: str | None = None ZULIP_API_KEY: str | None = None diff --git a/server/reflector/storage/__init__.py b/server/reflector/storage/__init__.py index 3db8a77b..aff6c767 100644 --- a/server/reflector/storage/__init__.py +++ b/server/reflector/storage/__init__.py @@ -3,6 +3,13 @@ from reflector.settings import settings def get_transcripts_storage() -> Storage: + """ + Get storage for processed transcript files (master credentials). + + Also use this for ALL our file operations with bucket override: + master = get_transcripts_storage() + master.delete_file(key, bucket=recording.bucket_name) + """ assert settings.TRANSCRIPT_STORAGE_BACKEND return Storage.get_instance( name=settings.TRANSCRIPT_STORAGE_BACKEND, @@ -10,8 +17,53 @@ def get_transcripts_storage() -> Storage: ) -def get_recordings_storage() -> Storage: +def get_whereby_storage() -> Storage: + """ + Get storage config for Whereby (for passing to Whereby API). + + Usage: + whereby_storage = get_whereby_storage() + key_id, secret = whereby_storage.key_credentials + whereby_api.create_meeting( + bucket=whereby_storage.bucket_name, + access_key_id=key_id, + secret=secret, + ) + + Do NOT use for our file operations - use get_transcripts_storage() instead. + """ + if not settings.WHEREBY_STORAGE_AWS_BUCKET_NAME: + raise ValueError( + "WHEREBY_STORAGE_AWS_BUCKET_NAME required for Whereby with AWS storage" + ) + return Storage.get_instance( - name=settings.RECORDING_STORAGE_BACKEND, - settings_prefix="RECORDING_STORAGE_", + name="aws", + settings_prefix="WHEREBY_STORAGE_", + ) + + +def get_dailyco_storage() -> Storage: + """ + Get storage config for Daily.co (for passing to Daily API). + + Usage: + daily_storage = get_dailyco_storage() + daily_api.create_meeting( + bucket=daily_storage.bucket_name, + region=daily_storage.region, + role_arn=daily_storage.role_credential, + ) + + Do NOT use for our file operations - use get_transcripts_storage() instead. + """ + # Fail fast if platform-specific config missing + if not settings.DAILYCO_STORAGE_AWS_BUCKET_NAME: + raise ValueError( + "DAILYCO_STORAGE_AWS_BUCKET_NAME required for Daily.co with AWS storage" + ) + + return Storage.get_instance( + name="aws", + settings_prefix="DAILYCO_STORAGE_", ) diff --git a/server/reflector/storage/base.py b/server/reflector/storage/base.py index 360930d8..ba4316d8 100644 --- a/server/reflector/storage/base.py +++ b/server/reflector/storage/base.py @@ -1,10 +1,23 @@ import importlib +from typing import BinaryIO, Union from pydantic import BaseModel from reflector.settings import settings +class StorageError(Exception): + """Base exception for storage operations.""" + + pass + + +class StoragePermissionError(StorageError): + """Exception raised when storage operation fails due to permission issues.""" + + pass + + class FileResult(BaseModel): filename: str url: str @@ -36,26 +49,113 @@ class Storage: return cls._registry[name](**config) - async def put_file(self, filename: str, data: bytes) -> FileResult: - return await self._put_file(filename, data) - - async def _put_file(self, filename: str, data: bytes) -> FileResult: + # Credential properties for API passthrough + @property + def bucket_name(self) -> str: + """Default bucket name for this storage instance.""" raise NotImplementedError - async def delete_file(self, filename: str): - return await self._delete_file(filename) - - async def _delete_file(self, filename: str): + @property + def region(self) -> str: + """AWS region for this storage instance.""" raise NotImplementedError - async def get_file_url(self, filename: str) -> str: - return await self._get_file_url(filename) + @property + def access_key_id(self) -> str | None: + """AWS access key ID (None for role-based auth). Prefer key_credentials property.""" + return None - async def _get_file_url(self, filename: str) -> str: + @property + def secret_access_key(self) -> str | None: + """AWS secret access key (None for role-based auth). Prefer key_credentials property.""" + return None + + @property + def role_arn(self) -> str | None: + """AWS IAM role ARN for role-based auth (None for key-based auth). Prefer role_credential property.""" + return None + + @property + def key_credentials(self) -> tuple[str, str]: + """ + Get (access_key_id, secret_access_key) for key-based auth. + Raises ValueError if storage uses IAM role instead. + """ raise NotImplementedError - async def get_file(self, filename: str): - return await self._get_file(filename) - - async def _get_file(self, filename: str): + @property + def role_credential(self) -> str: + """ + Get IAM role ARN for role-based auth. + Raises ValueError if storage uses access keys instead. + """ + raise NotImplementedError + + async def put_file( + self, filename: str, data: Union[bytes, BinaryIO], *, bucket: str | None = None + ) -> FileResult: + """Upload data. bucket: override instance default if provided.""" + return await self._put_file(filename, data, bucket=bucket) + + async def _put_file( + self, filename: str, data: Union[bytes, BinaryIO], *, bucket: str | None = None + ) -> FileResult: + raise NotImplementedError + + async def delete_file(self, filename: str, *, bucket: str | None = None): + """Delete file. bucket: override instance default if provided.""" + return await self._delete_file(filename, bucket=bucket) + + async def _delete_file(self, filename: str, *, bucket: str | None = None): + raise NotImplementedError + + async def get_file_url( + self, + filename: str, + operation: str = "get_object", + expires_in: int = 3600, + *, + bucket: str | None = None, + ) -> str: + """Generate presigned URL. bucket: override instance default if provided.""" + return await self._get_file_url(filename, operation, expires_in, bucket=bucket) + + async def _get_file_url( + self, + filename: str, + operation: str = "get_object", + expires_in: int = 3600, + *, + bucket: str | None = None, + ) -> str: + raise NotImplementedError + + async def get_file(self, filename: str, *, bucket: str | None = None): + """Download file. bucket: override instance default if provided.""" + return await self._get_file(filename, bucket=bucket) + + async def _get_file(self, filename: str, *, bucket: str | None = None): + raise NotImplementedError + + async def list_objects( + self, prefix: str = "", *, bucket: str | None = None + ) -> list[str]: + """List object keys. bucket: override instance default if provided.""" + return await self._list_objects(prefix, bucket=bucket) + + async def _list_objects( + self, prefix: str = "", *, bucket: str | None = None + ) -> list[str]: + raise NotImplementedError + + async def stream_to_fileobj( + self, filename: str, fileobj: BinaryIO, *, bucket: str | None = None + ): + """Stream file directly to file object without loading into memory. + bucket: override instance default if provided.""" + return await self._stream_to_fileobj(filename, fileobj, bucket=bucket) + + async def _stream_to_fileobj( + self, filename: str, fileobj: BinaryIO, *, bucket: str | None = None + ): raise NotImplementedError diff --git a/server/reflector/storage/storage_aws.py b/server/reflector/storage/storage_aws.py index de9ccf35..372af4aa 100644 --- a/server/reflector/storage/storage_aws.py +++ b/server/reflector/storage/storage_aws.py @@ -1,79 +1,236 @@ +from functools import wraps +from typing import BinaryIO, Union + import aioboto3 +from botocore.config import Config +from botocore.exceptions import ClientError from reflector.logger import logger -from reflector.storage.base import FileResult, Storage +from reflector.storage.base import FileResult, Storage, StoragePermissionError + + +def handle_s3_client_errors(operation_name: str): + """Decorator to handle S3 ClientError with bucket-aware messaging. + + Args: + operation_name: Human-readable operation name for error messages (e.g., "upload", "delete") + """ + + def decorator(func): + @wraps(func) + async def wrapper(self, *args, **kwargs): + bucket = kwargs.get("bucket") + try: + return await func(self, *args, **kwargs) + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code in ("AccessDenied", "NoSuchBucket"): + actual_bucket = bucket or self._bucket_name + bucket_context = ( + f"overridden bucket '{actual_bucket}'" + if bucket + else f"default bucket '{actual_bucket}'" + ) + raise StoragePermissionError( + f"S3 {operation_name} failed for {bucket_context}: {error_code}. " + f"Check TRANSCRIPT_STORAGE_AWS_* credentials have permission." + ) from e + raise + + return wrapper + + return decorator class AwsStorage(Storage): + """AWS S3 storage with bucket override for multi-platform recording architecture. + Master credentials access all buckets via optional bucket parameter in operations.""" + def __init__( self, - aws_access_key_id: str, - aws_secret_access_key: str, aws_bucket_name: str, aws_region: str, + aws_access_key_id: str | None = None, + aws_secret_access_key: str | None = None, + aws_role_arn: str | None = None, ): - if not aws_access_key_id: - raise ValueError("Storage `aws_storage` require `aws_access_key_id`") - if not aws_secret_access_key: - raise ValueError("Storage `aws_storage` require `aws_secret_access_key`") if not aws_bucket_name: raise ValueError("Storage `aws_storage` require `aws_bucket_name`") if not aws_region: raise ValueError("Storage `aws_storage` require `aws_region`") + if not aws_access_key_id and not aws_role_arn: + raise ValueError( + "Storage `aws_storage` require either `aws_access_key_id` or `aws_role_arn`" + ) + if aws_role_arn and (aws_access_key_id or aws_secret_access_key): + raise ValueError( + "Storage `aws_storage` cannot use both `aws_role_arn` and access keys" + ) super().__init__() - self.aws_bucket_name = aws_bucket_name + self._bucket_name = aws_bucket_name + self._region = aws_region + self._access_key_id = aws_access_key_id + self._secret_access_key = aws_secret_access_key + self._role_arn = aws_role_arn + self.aws_folder = "" if "/" in aws_bucket_name: - self.aws_bucket_name, self.aws_folder = aws_bucket_name.split("/", 1) + self._bucket_name, self.aws_folder = aws_bucket_name.split("/", 1) + self.boto_config = Config(retries={"max_attempts": 3, "mode": "adaptive"}) self.session = aioboto3.Session( aws_access_key_id=aws_access_key_id, aws_secret_access_key=aws_secret_access_key, region_name=aws_region, ) - self.base_url = f"https://{aws_bucket_name}.s3.amazonaws.com/" + self.base_url = f"https://{self._bucket_name}.s3.amazonaws.com/" - async def _put_file(self, filename: str, data: bytes) -> FileResult: - bucket = self.aws_bucket_name - folder = self.aws_folder - logger.info(f"Uploading {filename} to S3 {bucket}/{folder}") - s3filename = f"{folder}/{filename}" if folder else filename - async with self.session.client("s3") as client: - await client.put_object( - Bucket=bucket, - Key=s3filename, - Body=data, + # Implement credential properties + @property + def bucket_name(self) -> str: + return self._bucket_name + + @property + def region(self) -> str: + return self._region + + @property + def access_key_id(self) -> str | None: + return self._access_key_id + + @property + def secret_access_key(self) -> str | None: + return self._secret_access_key + + @property + def role_arn(self) -> str | None: + return self._role_arn + + @property + def key_credentials(self) -> tuple[str, str]: + """Get (access_key_id, secret_access_key) for key-based auth.""" + if self._role_arn: + raise ValueError( + "Storage uses IAM role authentication. " + "Use role_credential property instead of key_credentials." ) + if not self._access_key_id or not self._secret_access_key: + raise ValueError("Storage access key credentials not configured") + return (self._access_key_id, self._secret_access_key) - async def _get_file_url(self, filename: str) -> FileResult: - bucket = self.aws_bucket_name + @property + def role_credential(self) -> str: + """Get IAM role ARN for role-based auth.""" + if self._access_key_id or self._secret_access_key: + raise ValueError( + "Storage uses access key authentication. " + "Use key_credentials property instead of role_credential." + ) + if not self._role_arn: + raise ValueError("Storage IAM role ARN not configured") + return self._role_arn + + @handle_s3_client_errors("upload") + async def _put_file( + self, filename: str, data: Union[bytes, BinaryIO], *, bucket: str | None = None + ) -> FileResult: + actual_bucket = bucket or self._bucket_name folder = self.aws_folder s3filename = f"{folder}/{filename}" if folder else filename - async with self.session.client("s3") as client: + logger.info(f"Uploading {filename} to S3 {actual_bucket}/{folder}") + + async with self.session.client("s3", config=self.boto_config) as client: + if isinstance(data, bytes): + await client.put_object(Bucket=actual_bucket, Key=s3filename, Body=data) + else: + # boto3 reads file-like object in chunks + # avoids creating extra memory copy vs bytes.getvalue() approach + await client.upload_fileobj(data, Bucket=actual_bucket, Key=s3filename) + + url = await self._get_file_url(filename, bucket=bucket) + return FileResult(filename=filename, url=url) + + @handle_s3_client_errors("presign") + async def _get_file_url( + self, + filename: str, + operation: str = "get_object", + expires_in: int = 3600, + *, + bucket: str | None = None, + ) -> str: + actual_bucket = bucket or self._bucket_name + folder = self.aws_folder + s3filename = f"{folder}/{filename}" if folder else filename + async with self.session.client("s3", config=self.boto_config) as client: presigned_url = await client.generate_presigned_url( - "get_object", - Params={"Bucket": bucket, "Key": s3filename}, - ExpiresIn=3600, + operation, + Params={"Bucket": actual_bucket, "Key": s3filename}, + ExpiresIn=expires_in, ) return presigned_url - async def _delete_file(self, filename: str): - bucket = self.aws_bucket_name + @handle_s3_client_errors("delete") + async def _delete_file(self, filename: str, *, bucket: str | None = None): + actual_bucket = bucket or self._bucket_name folder = self.aws_folder - logger.info(f"Deleting {filename} from S3 {bucket}/{folder}") + logger.info(f"Deleting {filename} from S3 {actual_bucket}/{folder}") s3filename = f"{folder}/{filename}" if folder else filename - async with self.session.client("s3") as client: - await client.delete_object(Bucket=bucket, Key=s3filename) + async with self.session.client("s3", config=self.boto_config) as client: + await client.delete_object(Bucket=actual_bucket, Key=s3filename) - async def _get_file(self, filename: str): - bucket = self.aws_bucket_name + @handle_s3_client_errors("download") + async def _get_file(self, filename: str, *, bucket: str | None = None): + actual_bucket = bucket or self._bucket_name folder = self.aws_folder - logger.info(f"Downloading {filename} from S3 {bucket}/{folder}") + logger.info(f"Downloading {filename} from S3 {actual_bucket}/{folder}") s3filename = f"{folder}/{filename}" if folder else filename - async with self.session.client("s3") as client: - response = await client.get_object(Bucket=bucket, Key=s3filename) + async with self.session.client("s3", config=self.boto_config) as client: + response = await client.get_object(Bucket=actual_bucket, Key=s3filename) return await response["Body"].read() + @handle_s3_client_errors("list_objects") + async def _list_objects( + self, prefix: str = "", *, bucket: str | None = None + ) -> list[str]: + actual_bucket = bucket or self._bucket_name + folder = self.aws_folder + # Combine folder and prefix + s3prefix = f"{folder}/{prefix}" if folder else prefix + logger.info(f"Listing objects from S3 {actual_bucket} with prefix '{s3prefix}'") + + keys = [] + async with self.session.client("s3", config=self.boto_config) as client: + paginator = client.get_paginator("list_objects_v2") + async for page in paginator.paginate(Bucket=actual_bucket, Prefix=s3prefix): + if "Contents" in page: + for obj in page["Contents"]: + # Strip folder prefix from keys if present + key = obj["Key"] + if folder: + if key.startswith(f"{folder}/"): + key = key[len(folder) + 1 :] + elif key == folder: + # Skip folder marker itself + continue + keys.append(key) + + return keys + + @handle_s3_client_errors("stream") + async def _stream_to_fileobj( + self, filename: str, fileobj: BinaryIO, *, bucket: str | None = None + ): + """Stream file from S3 directly to file object without loading into memory.""" + actual_bucket = bucket or self._bucket_name + folder = self.aws_folder + logger.info(f"Streaming {filename} from S3 {actual_bucket}/{folder}") + s3filename = f"{folder}/{filename}" if folder else filename + async with self.session.client("s3", config=self.boto_config) as client: + await client.download_fileobj( + Bucket=actual_bucket, Key=s3filename, Fileobj=fileobj + ) + Storage.register("aws", AwsStorage) diff --git a/server/reflector/tools/cleanup_old_data.py b/server/reflector/tools/cleanup_old_data.py new file mode 100644 index 00000000..9ffa4684 --- /dev/null +++ b/server/reflector/tools/cleanup_old_data.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +""" +Manual cleanup tool for old public data. +Uses the same implementation as the Celery worker task. +""" + +import argparse +import asyncio +import sys + +import structlog + +from reflector.settings import settings +from reflector.worker.cleanup import _cleanup_old_public_data + +logger = structlog.get_logger(__name__) + + +async def cleanup_old_data(days: int = 7): + logger.info( + "Starting manual cleanup", + retention_days=days, + public_mode=settings.PUBLIC_MODE, + ) + + if not settings.PUBLIC_MODE: + logger.critical( + "WARNING: PUBLIC_MODE is False. " + "This tool is intended for public instances only." + ) + raise Exception("Tool intended for public instances only") + + result = await _cleanup_old_public_data(days=days) + + if result: + logger.info( + "Cleanup completed", + transcripts_deleted=result.get("transcripts_deleted", 0), + meetings_deleted=result.get("meetings_deleted", 0), + recordings_deleted=result.get("recordings_deleted", 0), + errors_count=len(result.get("errors", [])), + ) + if result.get("errors"): + logger.warning( + "Errors encountered during cleanup:", errors=result["errors"][:10] + ) + else: + logger.info("Cleanup skipped or completed without results") + + +def main(): + parser = argparse.ArgumentParser( + description="Clean up old transcripts and meetings" + ) + parser.add_argument( + "--days", + type=int, + default=7, + help="Number of days to keep data (default: 7)", + ) + + args = parser.parse_args() + + if args.days < 1: + logger.error("Days must be at least 1") + sys.exit(1) + + asyncio.run(cleanup_old_data(days=args.days)) + + +if __name__ == "__main__": + main() diff --git a/server/reflector/tools/cli_multitrack.py b/server/reflector/tools/cli_multitrack.py new file mode 100644 index 00000000..aad5ab2f --- /dev/null +++ b/server/reflector/tools/cli_multitrack.py @@ -0,0 +1,347 @@ +import asyncio +import sys +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Protocol + +import structlog +from celery.result import AsyncResult + +from reflector.db import get_database +from reflector.db.transcripts import SourceKind, Transcript, transcripts_controller +from reflector.pipelines.main_multitrack_pipeline import ( + task_pipeline_multitrack_process, +) +from reflector.storage import get_transcripts_storage +from reflector.tools.process import ( + extract_result_from_entry, + parse_s3_url, + validate_s3_objects, +) + +logger = structlog.get_logger(__name__) + +DEFAULT_PROCESSING_TIMEOUT_SECONDS = 3600 + +MAX_ERROR_MESSAGE_LENGTH = 500 + +TASK_POLL_INTERVAL_SECONDS = 2 + + +class StatusCallback(Protocol): + def __call__(self, state: str, elapsed_seconds: int) -> None: ... + + +@dataclass +class MultitrackTaskResult: + success: bool + transcript_id: str + error: Optional[str] = None + + +async def create_multitrack_transcript( + bucket_name: str, + track_keys: List[str], + source_language: str, + target_language: str, + user_id: Optional[str] = None, +) -> Transcript: + num_tracks = len(track_keys) + track_word = "track" if num_tracks == 1 else "tracks" + transcript_name = f"Multitrack ({num_tracks} {track_word})" + + transcript = await transcripts_controller.add( + transcript_name, + source_kind=SourceKind.FILE, + source_language=source_language, + target_language=target_language, + user_id=user_id, + ) + + logger.info( + "Created multitrack transcript", + transcript_id=transcript.id, + name=transcript_name, + bucket=bucket_name, + num_tracks=len(track_keys), + ) + + return transcript + + +def submit_multitrack_task( + transcript_id: str, bucket_name: str, track_keys: List[str] +) -> AsyncResult: + result = task_pipeline_multitrack_process.delay( + transcript_id=transcript_id, + bucket_name=bucket_name, + track_keys=track_keys, + ) + + logger.info( + "Multitrack task submitted", + transcript_id=transcript_id, + task_id=result.id, + bucket=bucket_name, + num_tracks=len(track_keys), + ) + + return result + + +async def wait_for_task( + result: AsyncResult, + transcript_id: str, + timeout_seconds: int = DEFAULT_PROCESSING_TIMEOUT_SECONDS, + poll_interval: int = TASK_POLL_INTERVAL_SECONDS, + status_callback: Optional[StatusCallback] = None, +) -> MultitrackTaskResult: + start_time = time.time() + last_status = None + + while not result.ready(): + elapsed = time.time() - start_time + if elapsed > timeout_seconds: + error_msg = ( + f"Task {result.id} did not complete within {timeout_seconds}s " + f"for transcript {transcript_id}" + ) + logger.error( + "Task timeout", + task_id=result.id, + transcript_id=transcript_id, + elapsed_seconds=elapsed, + ) + raise TimeoutError(error_msg) + + if result.state != last_status: + if status_callback: + status_callback(result.state, int(elapsed)) + last_status = result.state + + await asyncio.sleep(poll_interval) + + if result.failed(): + error_info = result.info + traceback_info = getattr(result, "traceback", None) + + logger.error( + "Multitrack task failed", + transcript_id=transcript_id, + task_id=result.id, + error=str(error_info), + has_traceback=bool(traceback_info), + ) + + error_detail = str(error_info) + if traceback_info: + error_detail += f"\nTraceback:\n{traceback_info}" + + return MultitrackTaskResult( + success=False, transcript_id=transcript_id, error=error_detail + ) + + logger.info( + "Multitrack task completed", + transcript_id=transcript_id, + task_id=result.id, + state=result.state, + ) + + return MultitrackTaskResult(success=True, transcript_id=transcript_id) + + +async def update_transcript_status( + transcript_id: str, + status: str, + error: Optional[str] = None, + max_error_length: int = MAX_ERROR_MESSAGE_LENGTH, +) -> None: + database = get_database() + connected = False + + try: + await database.connect() + connected = True + + transcript = await transcripts_controller.get_by_id(transcript_id) + if transcript: + update_data: Dict[str, Any] = {"status": status} + + if error: + if len(error) > max_error_length: + error = error[: max_error_length - 3] + "..." + update_data["error"] = error + + await transcripts_controller.update(transcript, update_data) + + logger.info( + "Updated transcript status", + transcript_id=transcript_id, + status=status, + has_error=bool(error), + ) + except Exception as e: + logger.warning( + "Failed to update transcript status", + transcript_id=transcript_id, + error=str(e), + ) + finally: + if connected: + try: + await database.disconnect() + except Exception as e: + logger.warning(f"Database disconnect failed: {e}") + + +async def process_multitrack( + bucket_name: str, + track_keys: List[str], + source_language: str, + target_language: str, + user_id: Optional[str] = None, + timeout_seconds: int = DEFAULT_PROCESSING_TIMEOUT_SECONDS, + status_callback: Optional[StatusCallback] = None, +) -> MultitrackTaskResult: + """High-level orchestration for multitrack processing.""" + database = get_database() + transcript = None + connected = False + + try: + await database.connect() + connected = True + + transcript = await create_multitrack_transcript( + bucket_name=bucket_name, + track_keys=track_keys, + source_language=source_language, + target_language=target_language, + user_id=user_id, + ) + + result = submit_multitrack_task( + transcript_id=transcript.id, bucket_name=bucket_name, track_keys=track_keys + ) + + except Exception as e: + if transcript: + try: + await update_transcript_status( + transcript_id=transcript.id, status="failed", error=str(e) + ) + except Exception as update_error: + logger.error( + "Failed to update transcript status after error", + original_error=str(e), + update_error=str(update_error), + transcript_id=transcript.id, + ) + raise + finally: + if connected: + try: + await database.disconnect() + except Exception as e: + logger.warning(f"Database disconnect failed: {e}") + + # Poll outside database connection + task_result = await wait_for_task( + result=result, + transcript_id=transcript.id, + timeout_seconds=timeout_seconds, + poll_interval=2, + status_callback=status_callback, + ) + + if not task_result.success: + await update_transcript_status( + transcript_id=transcript.id, status="failed", error=task_result.error + ) + + return task_result + + +def print_progress(message: str) -> None: + """Print progress message to stderr for CLI visibility.""" + print(f"{message}", file=sys.stderr) + + +def create_status_callback() -> StatusCallback: + """Create callback for task status updates during polling.""" + + def callback(state: str, elapsed_seconds: int) -> None: + print_progress( + f"Multitrack pipeline status: {state} (elapsed: {elapsed_seconds}s)" + ) + + return callback + + +async def process_multitrack_cli( + s3_urls: List[str], + source_language: str, + target_language: str, + output_path: Optional[str] = None, +) -> None: + if not s3_urls: + raise ValueError("At least one track required for multitrack processing") + + bucket_keys = [] + for url in s3_urls: + try: + bucket, key = parse_s3_url(url) + bucket_keys.append((bucket, key)) + except ValueError as e: + raise ValueError(f"Invalid S3 URL '{url}': {e}") from e + + buckets = set(bucket for bucket, _ in bucket_keys) + if len(buckets) > 1: + raise ValueError( + f"All tracks must be in the same S3 bucket. " + f"Found {len(buckets)} different buckets: {sorted(buckets)}. " + f"Please upload all files to a single bucket." + ) + + primary_bucket = bucket_keys[0][0] + track_keys = [key for _, key in bucket_keys] + + print_progress( + f"Starting multitrack CLI processing: " + f"bucket={primary_bucket}, num_tracks={len(track_keys)}, " + f"source_language={source_language}, target_language={target_language}" + ) + + storage = get_transcripts_storage() + await validate_s3_objects(storage, bucket_keys) + print_progress(f"S3 validation complete: {len(bucket_keys)} objects verified") + + result = await process_multitrack( + bucket_name=primary_bucket, + track_keys=track_keys, + source_language=source_language, + target_language=target_language, + user_id=None, + timeout_seconds=3600, + status_callback=create_status_callback(), + ) + + if not result.success: + error_msg = ( + f"Multitrack pipeline failed for transcript {result.transcript_id}\n" + ) + if result.error: + error_msg += f"Error: {result.error}\n" + raise RuntimeError(error_msg) + + print_progress( + f"Multitrack processing complete for transcript {result.transcript_id}" + ) + + database = get_database() + await database.connect() + try: + await extract_result_from_entry(result.transcript_id, output_path) + finally: + await database.disconnect() diff --git a/server/reflector/tools/process.py b/server/reflector/tools/process.py index 4f1cafdd..a3a74138 100644 --- a/server/reflector/tools/process.py +++ b/server/reflector/tools/process.py @@ -1,294 +1,317 @@ """ Process audio file with diarization support -=========================================== - -Extended version of process.py that includes speaker diarization. -This tool processes audio files locally without requiring the full server infrastructure. """ +import argparse import asyncio -import tempfile -import uuid +import json +import shutil +import sys +import time from pathlib import Path -from typing import List +from typing import Any, Dict, List, Literal, Tuple +from urllib.parse import unquote, urlparse -import av +from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError +from reflector.db.transcripts import SourceKind, TranscriptTopic, transcripts_controller from reflector.logger import logger -from reflector.processors import ( - AudioChunkerAutoProcessor, - AudioDownscaleProcessor, - AudioFileWriterProcessor, - AudioMergeProcessor, - AudioTranscriptAutoProcessor, - Pipeline, - PipelineEvent, - TranscriptFinalSummaryProcessor, - TranscriptFinalTitleProcessor, - TranscriptLinerProcessor, - TranscriptTopicDetectorProcessor, - TranscriptTranslatorAutoProcessor, +from reflector.pipelines.main_file_pipeline import ( + task_pipeline_file_process as task_pipeline_file_process, ) -from reflector.processors.base import BroadcastProcessor, Processor -from reflector.processors.types import ( - AudioDiarizationInput, - TitleSummary, - TitleSummaryWithId, +from reflector.pipelines.main_live_pipeline import pipeline_post as live_pipeline_post +from reflector.pipelines.main_live_pipeline import ( + pipeline_process as live_pipeline_process, ) +from reflector.storage import Storage -class TopicCollectorProcessor(Processor): - """Collect topics for diarization""" - - INPUT_TYPE = TitleSummary - OUTPUT_TYPE = TitleSummary - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.topics: List[TitleSummaryWithId] = [] - self._topic_id = 0 - - async def _push(self, data: TitleSummary): - # Convert to TitleSummaryWithId and collect - self._topic_id += 1 - topic_with_id = TitleSummaryWithId( - id=str(self._topic_id), - title=data.title, - summary=data.summary, - timestamp=data.timestamp, - duration=data.duration, - transcript=data.transcript, - ) - self.topics.append(topic_with_id) - - # Pass through the original topic - await self.emit(data) - - def get_topics(self) -> List[TitleSummaryWithId]: - return self.topics +def validate_s3_bucket_name(bucket: str) -> None: + if not bucket: + raise ValueError("Bucket name cannot be empty") + if len(bucket) > 255: # Absolute max for any region + raise ValueError(f"Bucket name too long: {len(bucket)} characters (max 255)") -async def process_audio_file( - filename, - event_callback, - only_transcript=False, - source_language="en", - target_language="en", - enable_diarization=True, - diarization_backend="pyannote", -): - # Create temp file for audio if diarization is enabled - audio_temp_path = None - if enable_diarization: - audio_temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - audio_temp_path = audio_temp_file.name - audio_temp_file.close() +def validate_s3_key(key: str) -> None: + if not key: + raise ValueError("S3 key cannot be empty") + if len(key) > 1024: + raise ValueError(f"S3 key too long: {len(key)} characters (max 1024)") - # Create processor for collecting topics - topic_collector = TopicCollectorProcessor() - # Build pipeline for audio processing - processors = [] +def parse_s3_url(url: str) -> Tuple[str, str]: + parsed = urlparse(url) - # Add audio file writer at the beginning if diarization is enabled - if enable_diarization: - processors.append(AudioFileWriterProcessor(audio_temp_path)) + if parsed.scheme == "s3": + bucket = parsed.netloc + key = parsed.path.lstrip("/") + if parsed.fragment: + logger.debug( + "URL fragment ignored (not part of S3 key)", + url=url, + fragment=parsed.fragment, + ) + if not bucket or not key: + raise ValueError(f"Invalid S3 URL: {url} (missing bucket or key)") + bucket = unquote(bucket) + key = unquote(key) + validate_s3_bucket_name(bucket) + validate_s3_key(key) + return bucket, key - # Add the rest of the processors - processors += [ - AudioDownscaleProcessor(), - AudioChunkerAutoProcessor(), - AudioMergeProcessor(), - AudioTranscriptAutoProcessor.as_threaded(), - TranscriptLinerProcessor(), - TranscriptTranslatorAutoProcessor.as_threaded(), - ] + elif parsed.scheme in ("http", "https"): + if ".s3." in parsed.netloc or parsed.netloc.endswith(".s3.amazonaws.com"): + bucket = parsed.netloc.split(".")[0] + key = parsed.path.lstrip("/") + if parsed.fragment: + logger.debug("URL fragment ignored", url=url, fragment=parsed.fragment) + if not bucket or not key: + raise ValueError(f"Invalid S3 URL: {url} (missing bucket or key)") + bucket = unquote(bucket) + key = unquote(key) + validate_s3_bucket_name(bucket) + validate_s3_key(key) + return bucket, key - if not only_transcript: - processors += [ - TranscriptTopicDetectorProcessor.as_threaded(), - # Collect topics for diarization - topic_collector, - BroadcastProcessor( - processors=[ - TranscriptFinalTitleProcessor.as_threaded(), - TranscriptFinalSummaryProcessor.as_threaded(), - ], - ), - ] + elif parsed.netloc.startswith("s3.") and "amazonaws.com" in parsed.netloc: + path_parts = parsed.path.lstrip("/").split("/", 1) + if len(path_parts) != 2: + raise ValueError(f"Invalid S3 URL: {url} (missing bucket or key)") + bucket, key = path_parts + if parsed.fragment: + logger.debug("URL fragment ignored", url=url, fragment=parsed.fragment) + bucket = unquote(bucket) + key = unquote(key) + validate_s3_bucket_name(bucket) + validate_s3_key(key) + return bucket, key - # Create main pipeline - pipeline = Pipeline(*processors) - pipeline.set_pref("audio:source_language", source_language) - pipeline.set_pref("audio:target_language", target_language) - pipeline.describe() - pipeline.on(event_callback) - - # Start processing audio - logger.info(f"Opening {filename}") - container = av.open(filename) - try: - logger.info("Start pushing audio into the pipeline") - for frame in container.decode(audio=0): - await pipeline.push(frame) - finally: - logger.info("Flushing the pipeline") - await pipeline.flush() - - # Run diarization if enabled and we have topics - if enable_diarization and not only_transcript and audio_temp_path: - topics = topic_collector.get_topics() - - if topics: - logger.info(f"Starting diarization with {len(topics)} topics") - - try: - from reflector.processors import AudioDiarizationAutoProcessor - - diarization_processor = AudioDiarizationAutoProcessor( - name=diarization_backend - ) - - diarization_processor.set_pipeline(pipeline) - - # For Modal backend, we need to upload the file to S3 first - if diarization_backend == "modal": - from datetime import datetime - - from reflector.storage import get_transcripts_storage - from reflector.utils.s3_temp_file import S3TemporaryFile - - storage = get_transcripts_storage() - - # Generate a unique filename in evaluation folder - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") - audio_filename = f"evaluation/diarization_temp/{timestamp}_{uuid.uuid4().hex}.wav" - - # Use context manager for automatic cleanup - async with S3TemporaryFile(storage, audio_filename) as s3_file: - # Read and upload the audio file - with open(audio_temp_path, "rb") as f: - audio_data = f.read() - - audio_url = await s3_file.upload(audio_data) - logger.info(f"Uploaded audio to S3: {audio_filename}") - - # Create diarization input with S3 URL - diarization_input = AudioDiarizationInput( - audio_url=audio_url, topics=topics - ) - - # Run diarization - await diarization_processor.push(diarization_input) - await diarization_processor.flush() - - logger.info("Diarization complete") - # File will be automatically cleaned up when exiting the context - else: - # For local backend, use local file path - audio_url = audio_temp_path - - # Create diarization input - diarization_input = AudioDiarizationInput( - audio_url=audio_url, topics=topics - ) - - # Run diarization - await diarization_processor.push(diarization_input) - await diarization_processor.flush() - - logger.info("Diarization complete") - - except ImportError as e: - logger.error(f"Failed to import diarization dependencies: {e}") - logger.error( - "Install with: uv pip install pyannote.audio torch torchaudio" - ) - logger.error( - "And set HF_TOKEN environment variable for pyannote models" - ) - raise SystemExit(1) - except Exception as e: - logger.error(f"Diarization failed: {e}") - raise SystemExit(1) else: - logger.warning("Skipping diarization: no topics available") + raise ValueError(f"Invalid S3 URL format: {url} (not recognized as S3 URL)") - # Clean up temp file - if audio_temp_path: - try: - Path(audio_temp_path).unlink() - except Exception as e: - logger.warning(f"Failed to clean up temp file {audio_temp_path}: {e}") + else: + raise ValueError(f"Invalid S3 URL scheme: {url} (must be s3:// or https://)") - logger.info("All done!") + +async def validate_s3_objects( + storage: Storage, bucket_keys: List[Tuple[str, str]] +) -> None: + async with storage.session.client("s3") as client: + + async def check_object(bucket: str, key: str) -> None: + try: + await client.head_object(Bucket=bucket, Key=key) + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code in ("404", "NoSuchKey"): + raise ValueError(f"S3 object not found: s3://{bucket}/{key}") from e + elif error_code in ("403", "Forbidden", "AccessDenied"): + raise ValueError( + f"Access denied for S3 object: s3://{bucket}/{key}. " + f"Check AWS credentials and permissions" + ) from e + else: + raise ValueError( + f"S3 error {error_code} for s3://{bucket}/{key}: " + f"{e.response['Error'].get('Message', 'Unknown error')}" + ) from e + except NoCredentialsError as e: + raise ValueError( + "AWS credentials not configured. Set AWS_ACCESS_KEY_ID and " + "AWS_SECRET_ACCESS_KEY environment variables" + ) from e + except BotoCoreError as e: + raise ValueError( + f"AWS service error for s3://{bucket}/{key}: {str(e)}" + ) from e + except Exception as e: + raise ValueError( + f"Unexpected error validating s3://{bucket}/{key}: {str(e)}" + ) from e + + await asyncio.gather( + *(check_object(bucket, key) for bucket, key in bucket_keys) + ) + + +def serialize_topics(topics: List[TranscriptTopic]) -> List[Dict[str, Any]]: + serialized = [] + for topic in topics: + topic_dict = topic.model_dump() + serialized.append(topic_dict) + return serialized + + +def debug_print_speakers(serialized_topics: List[Dict[str, Any]]) -> None: + all_speakers = set() + for topic_dict in serialized_topics: + for word in topic_dict.get("words", []): + all_speakers.add(word.get("speaker", 0)) + + print( + f"Found {len(serialized_topics)} topics with speakers: {all_speakers}", + file=sys.stderr, + ) + + +TranscriptId = str + + +async def prepare_entry( + source_path: str, + source_language: str, + target_language: str, +) -> TranscriptId: + file_path = Path(source_path) + + transcript = await transcripts_controller.add( + file_path.name, + # note that the real file upload has SourceKind: LIVE for the reason of it's an error + source_kind=SourceKind.FILE, + source_language=source_language, + target_language=target_language, + user_id=None, + ) + + logger.info(f"Created transcript {transcript.id} for {file_path.name}") + + # pipelines expect files as upload.* + + extension = file_path.suffix + upload_path = transcript.data_path / f"upload{extension}" + upload_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, upload_path) + logger.info(f"Copied {source_path} to {upload_path}") + + # pipelines expect entity status "uploaded" + await transcripts_controller.update(transcript, {"status": "uploaded"}) + + return transcript.id + + +async def extract_result_from_entry( + transcript_id: TranscriptId, output_path: str +) -> None: + post_final_transcript = await transcripts_controller.get_by_id(transcript_id) + + # assert post_final_transcript.status == "ended" + # File pipeline doesn't set status to "ended", only live pipeline does https://github.com/Monadical-SAS/reflector/issues/582 + topics = post_final_transcript.topics + if not topics: + raise RuntimeError( + f"No topics found for transcript {transcript_id} after processing" + ) + + serialized_topics = serialize_topics(topics) + + if output_path: + # Write to JSON file + with open(output_path, "w") as f: + for topic_dict in serialized_topics: + json.dump(topic_dict, f) + f.write("\n") + print(f"Results written to {output_path}", file=sys.stderr) + else: + # Write to stdout as JSONL + for topic_dict in serialized_topics: + print(json.dumps(topic_dict)) + + debug_print_speakers(serialized_topics) + + +async def process_live_pipeline( + transcript_id: TranscriptId, +): + """Process transcript_id with transcription and diarization""" + + print(f"Processing transcript_id {transcript_id}...", file=sys.stderr) + await live_pipeline_process(transcript_id=transcript_id) + print(f"Processing complete for transcript {transcript_id}", file=sys.stderr) + + pre_final_transcript = await transcripts_controller.get_by_id(transcript_id) + + # assert documented behaviour: after process, the pipeline isn't ended. this is the reason of calling pipeline_post + assert pre_final_transcript.status != "ended" + + # at this point, diarization is running but we have no access to it. run diarization in parallel - one will hopefully win after polling + result = live_pipeline_post(transcript_id=transcript_id) + + # result.ready() blocks even without await; it mutates result also + while not result.ready(): + print(f"Status: {result.state}") + time.sleep(2) async def process_file_pipeline( - filename: str, - event_callback, - source_language="en", - target_language="en", - enable_diarization=True, - diarization_backend="modal", + transcript_id: TranscriptId, ): """Process audio/video file using the optimized file pipeline""" + + # task_pipeline_file_process is a Celery task, need to use .delay() for async execution + result = task_pipeline_file_process.delay(transcript_id=transcript_id) + + # Wait for the Celery task to complete + while not result.ready(): + print(f"File pipeline status: {result.state}", file=sys.stderr) + time.sleep(2) + + logger.info("File pipeline processing complete") + + +async def process( + source_path: str, + source_language: str, + target_language: str, + pipeline: Literal["live", "file"], + output_path: str = None, +): + from reflector.db import get_database + + database = get_database() + # db connect is a part of ceremony + await database.connect() + try: - from reflector.db import database - from reflector.db.transcripts import SourceKind, transcripts_controller - from reflector.pipelines.main_file_pipeline import PipelineMainFile - - await database.connect() - try: - # Create a temporary transcript for processing - transcript = await transcripts_controller.add( - "", - source_kind=SourceKind.FILE, - source_language=source_language, - target_language=target_language, - ) - - # Process the file - pipeline = PipelineMainFile(transcript_id=transcript.id) - await pipeline.process(Path(filename)) - - logger.info("File pipeline processing complete") - - finally: - await database.disconnect() - except ImportError as e: - logger.error(f"File pipeline not available: {e}") - logger.info("Falling back to stream pipeline") - # Fall back to stream pipeline - await process_audio_file( - filename, - event_callback, - only_transcript=False, - source_language=source_language, - target_language=target_language, - enable_diarization=enable_diarization, - diarization_backend=diarization_backend, + transcript_id = await prepare_entry( + source_path, + source_language, + target_language, ) + pipeline_handlers = { + "live": process_live_pipeline, + "file": process_file_pipeline, + } + + handler = pipeline_handlers.get(pipeline) + if not handler: + raise ValueError(f"Unknown pipeline type: {pipeline}") + + await handler(transcript_id) + + await extract_result_from_entry(transcript_id, output_path) + finally: + await database.disconnect() + if __name__ == "__main__": - import argparse - import os - parser = argparse.ArgumentParser( - description="Process audio files with optional speaker diarization" - ) - parser.add_argument("source", help="Source file (mp3, wav, mp4...)") - parser.add_argument( - "--stream", - action="store_true", - help="Use streaming pipeline (original frame-based processing)", + description="Process audio files with speaker diarization" ) parser.add_argument( - "--only-transcript", - "-t", + "source", + help="Source file (mp3, wav, mp4...) or comma-separated S3 URLs with --multitrack", + ) + parser.add_argument( + "--pipeline", + choices=["live", "file"], + help="Pipeline type to use for processing (live: streaming/incremental, file: batch/parallel)", + ) + parser.add_argument( + "--multitrack", action="store_true", - help="Only generate transcript without topics/summaries", + help="Process multiple audio tracks from comma-separated S3 URLs", ) parser.add_argument( "--source-language", default="en", help="Source language code (default: en)" @@ -297,82 +320,42 @@ if __name__ == "__main__": "--target-language", default="en", help="Target language code (default: en)" ) parser.add_argument("--output", "-o", help="Output file (output.jsonl)") - parser.add_argument( - "--enable-diarization", - "-d", - action="store_true", - help="Enable speaker diarization", - ) - parser.add_argument( - "--diarization-backend", - default="pyannote", - choices=["pyannote", "modal"], - help="Diarization backend to use (default: pyannote)", - ) args = parser.parse_args() - if "REDIS_HOST" not in os.environ: - os.environ["REDIS_HOST"] = "localhost" + if args.multitrack: + if not args.source: + parser.error("Source URLs required for multitrack processing") - output_fd = None - if args.output: - output_fd = open(args.output, "w") + s3_urls = [url.strip() for url in args.source.split(",") if url.strip()] - async def event_callback(event: PipelineEvent): - processor = event.processor - data = event.data + if not s3_urls: + parser.error("At least one S3 URL required for multitrack processing") - # Ignore internal processors - if processor in ( - "AudioDownscaleProcessor", - "AudioChunkerAutoProcessor", - "AudioMergeProcessor", - "AudioFileWriterProcessor", - "TopicCollectorProcessor", - "BroadcastProcessor", - ): - return + from reflector.tools.cli_multitrack import process_multitrack_cli - # If diarization is enabled, skip the original topic events from the pipeline - # The diarization processor will emit the same topics but with speaker info - if processor == "TranscriptTopicDetectorProcessor" and args.enable_diarization: - return - - # Log all events - logger.info(f"Event: {processor} - {type(data).__name__}") - - # Write to output - if output_fd: - output_fd.write(event.model_dump_json()) - output_fd.write("\n") - output_fd.flush() - - if args.stream: - # Use original streaming pipeline asyncio.run( - process_audio_file( - args.source, - event_callback, - only_transcript=args.only_transcript, - source_language=args.source_language, - target_language=args.target_language, - enable_diarization=args.enable_diarization, - diarization_backend=args.diarization_backend, + process_multitrack_cli( + s3_urls, + args.source_language, + args.target_language, + args.output, ) ) else: - # Use optimized file pipeline (default) + if not args.pipeline: + parser.error("--pipeline is required for single-track processing") + + if "," in args.source: + parser.error( + "Multiple files detected. Use --multitrack flag for multitrack processing" + ) + asyncio.run( - process_file_pipeline( + process( args.source, - event_callback, - source_language=args.source_language, - target_language=args.target_language, - enable_diarization=args.enable_diarization, - diarization_backend=args.diarization_backend, + args.source_language, + args.target_language, + args.pipeline, + args.output, ) ) - - if output_fd: - output_fd.close() - logger.info(f"Output written to {args.output}") diff --git a/server/reflector/tools/process_with_diarization.py b/server/reflector/tools/process_with_diarization.py deleted file mode 100644 index f1415e1a..00000000 --- a/server/reflector/tools/process_with_diarization.py +++ /dev/null @@ -1,318 +0,0 @@ -""" -@vibe-generated -Process audio file with diarization support -=========================================== - -Extended version of process.py that includes speaker diarization. -This tool processes audio files locally without requiring the full server infrastructure. -""" - -import asyncio -import tempfile -import uuid -from pathlib import Path -from typing import List - -import av - -from reflector.logger import logger -from reflector.processors import ( - AudioChunkerAutoProcessor, - AudioDownscaleProcessor, - AudioFileWriterProcessor, - AudioMergeProcessor, - AudioTranscriptAutoProcessor, - Pipeline, - PipelineEvent, - TranscriptFinalSummaryProcessor, - TranscriptFinalTitleProcessor, - TranscriptLinerProcessor, - TranscriptTopicDetectorProcessor, - TranscriptTranslatorAutoProcessor, -) -from reflector.processors.base import BroadcastProcessor, Processor -from reflector.processors.types import ( - AudioDiarizationInput, - TitleSummary, - TitleSummaryWithId, -) - - -class TopicCollectorProcessor(Processor): - """Collect topics for diarization""" - - INPUT_TYPE = TitleSummary - OUTPUT_TYPE = TitleSummary - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.topics: List[TitleSummaryWithId] = [] - self._topic_id = 0 - - async def _push(self, data: TitleSummary): - # Convert to TitleSummaryWithId and collect - self._topic_id += 1 - topic_with_id = TitleSummaryWithId( - id=str(self._topic_id), - title=data.title, - summary=data.summary, - timestamp=data.timestamp, - duration=data.duration, - transcript=data.transcript, - ) - self.topics.append(topic_with_id) - - # Pass through the original topic - await self.emit(data) - - def get_topics(self) -> List[TitleSummaryWithId]: - return self.topics - - -async def process_audio_file_with_diarization( - filename, - event_callback, - only_transcript=False, - source_language="en", - target_language="en", - enable_diarization=True, - diarization_backend="modal", -): - # Create temp file for audio if diarization is enabled - audio_temp_path = None - if enable_diarization: - audio_temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) - audio_temp_path = audio_temp_file.name - audio_temp_file.close() - - # Create processor for collecting topics - topic_collector = TopicCollectorProcessor() - - # Build pipeline for audio processing - processors = [] - - # Add audio file writer at the beginning if diarization is enabled - if enable_diarization: - processors.append(AudioFileWriterProcessor(audio_temp_path)) - - # Add the rest of the processors - processors += [ - AudioDownscaleProcessor(), - AudioChunkerAutoProcessor(), - AudioMergeProcessor(), - AudioTranscriptAutoProcessor.as_threaded(), - ] - - processors += [ - TranscriptLinerProcessor(), - TranscriptTranslatorAutoProcessor.as_threaded(), - ] - - if not only_transcript: - processors += [ - TranscriptTopicDetectorProcessor.as_threaded(), - # Collect topics for diarization - topic_collector, - BroadcastProcessor( - processors=[ - TranscriptFinalTitleProcessor.as_threaded(), - TranscriptFinalSummaryProcessor.as_threaded(), - ], - ), - ] - - # Create main pipeline - pipeline = Pipeline(*processors) - pipeline.set_pref("audio:source_language", source_language) - pipeline.set_pref("audio:target_language", target_language) - pipeline.describe() - pipeline.on(event_callback) - - # Start processing audio - logger.info(f"Opening {filename}") - container = av.open(filename) - try: - logger.info("Start pushing audio into the pipeline") - for frame in container.decode(audio=0): - await pipeline.push(frame) - finally: - logger.info("Flushing the pipeline") - await pipeline.flush() - - # Run diarization if enabled and we have topics - if enable_diarization and not only_transcript and audio_temp_path: - topics = topic_collector.get_topics() - - if topics: - logger.info(f"Starting diarization with {len(topics)} topics") - - try: - from reflector.processors import AudioDiarizationAutoProcessor - - diarization_processor = AudioDiarizationAutoProcessor( - name=diarization_backend - ) - - diarization_processor.set_pipeline(pipeline) - - # For Modal backend, we need to upload the file to S3 first - if diarization_backend == "modal": - from datetime import datetime, timezone - - from reflector.storage import get_transcripts_storage - from reflector.utils.s3_temp_file import S3TemporaryFile - - storage = get_transcripts_storage() - - # Generate a unique filename in evaluation folder - timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - audio_filename = f"evaluation/diarization_temp/{timestamp}_{uuid.uuid4().hex}.wav" - - # Use context manager for automatic cleanup - async with S3TemporaryFile(storage, audio_filename) as s3_file: - # Read and upload the audio file - with open(audio_temp_path, "rb") as f: - audio_data = f.read() - - audio_url = await s3_file.upload(audio_data) - logger.info(f"Uploaded audio to S3: {audio_filename}") - - # Create diarization input with S3 URL - diarization_input = AudioDiarizationInput( - audio_url=audio_url, topics=topics - ) - - # Run diarization - await diarization_processor.push(diarization_input) - await diarization_processor.flush() - - logger.info("Diarization complete") - # File will be automatically cleaned up when exiting the context - else: - # For local backend, use local file path - audio_url = audio_temp_path - - # Create diarization input - diarization_input = AudioDiarizationInput( - audio_url=audio_url, topics=topics - ) - - # Run diarization - await diarization_processor.push(diarization_input) - await diarization_processor.flush() - - logger.info("Diarization complete") - - except ImportError as e: - logger.error(f"Failed to import diarization dependencies: {e}") - logger.error( - "Install with: uv pip install pyannote.audio torch torchaudio" - ) - logger.error( - "And set HF_TOKEN environment variable for pyannote models" - ) - raise SystemExit(1) - except Exception as e: - logger.error(f"Diarization failed: {e}") - raise SystemExit(1) - else: - logger.warning("Skipping diarization: no topics available") - - # Clean up temp file - if audio_temp_path: - try: - Path(audio_temp_path).unlink() - except Exception as e: - logger.warning(f"Failed to clean up temp file {audio_temp_path}: {e}") - - logger.info("All done!") - - -if __name__ == "__main__": - import argparse - import os - - parser = argparse.ArgumentParser( - description="Process audio files with optional speaker diarization" - ) - parser.add_argument("source", help="Source file (mp3, wav, mp4...)") - parser.add_argument( - "--only-transcript", - "-t", - action="store_true", - help="Only generate transcript without topics/summaries", - ) - parser.add_argument( - "--source-language", default="en", help="Source language code (default: en)" - ) - parser.add_argument( - "--target-language", default="en", help="Target language code (default: en)" - ) - parser.add_argument("--output", "-o", help="Output file (output.jsonl)") - parser.add_argument( - "--enable-diarization", - "-d", - action="store_true", - help="Enable speaker diarization", - ) - parser.add_argument( - "--diarization-backend", - default="modal", - choices=["modal"], - help="Diarization backend to use (default: modal)", - ) - args = parser.parse_args() - - # Set REDIS_HOST to localhost if not provided - if "REDIS_HOST" not in os.environ: - os.environ["REDIS_HOST"] = "localhost" - logger.info("REDIS_HOST not set, defaulting to localhost") - - output_fd = None - if args.output: - output_fd = open(args.output, "w") - - async def event_callback(event: PipelineEvent): - processor = event.processor - data = event.data - - # Ignore internal processors - if processor in ( - "AudioDownscaleProcessor", - "AudioChunkerAutoProcessor", - "AudioMergeProcessor", - "AudioFileWriterProcessor", - "TopicCollectorProcessor", - "BroadcastProcessor", - ): - return - - # If diarization is enabled, skip the original topic events from the pipeline - # The diarization processor will emit the same topics but with speaker info - if processor == "TranscriptTopicDetectorProcessor" and args.enable_diarization: - return - - # Log all events - logger.info(f"Event: {processor} - {type(data).__name__}") - - # Write to output - if output_fd: - output_fd.write(event.model_dump_json()) - output_fd.write("\n") - output_fd.flush() - - asyncio.run( - process_audio_file_with_diarization( - args.source, - event_callback, - only_transcript=args.only_transcript, - source_language=args.source_language, - target_language=args.target_language, - enable_diarization=args.enable_diarization, - diarization_backend=args.diarization_backend, - ) - ) - - if output_fd: - output_fd.close() - logger.info(f"Output written to {args.output}") diff --git a/server/reflector/tools/test_diarization.py b/server/reflector/tools/test_diarization.py deleted file mode 100644 index bd071d96..00000000 --- a/server/reflector/tools/test_diarization.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -""" -@vibe-generated -Test script for the diarization CLI tool -========================================= - -This script helps test the diarization functionality with sample audio files. -""" - -import asyncio -import sys -from pathlib import Path - -from reflector.logger import logger - - -async def test_diarization(audio_file: str): - """Test the diarization functionality""" - - # Import the processing function - from process_with_diarization import process_audio_file_with_diarization - - # Collect events - events = [] - - async def event_callback(event): - events.append({"processor": event.processor, "data": event.data}) - logger.info(f"Event from {event.processor}") - - # Process the audio file - logger.info(f"Processing audio file: {audio_file}") - - try: - await process_audio_file_with_diarization( - audio_file, - event_callback, - only_transcript=False, - source_language="en", - target_language="en", - enable_diarization=True, - diarization_backend="modal", - ) - - # Analyze results - logger.info(f"Processing complete. Received {len(events)} events") - - # Look for diarization results - diarized_topics = [] - for event in events: - if "TitleSummary" in event["processor"]: - # Check if words have speaker information - if hasattr(event["data"], "transcript") and event["data"].transcript: - words = event["data"].transcript.words - if words and hasattr(words[0], "speaker"): - speakers = set( - w.speaker for w in words if hasattr(w, "speaker") - ) - logger.info( - f"Found {len(speakers)} speakers in topic: {event['data'].title}" - ) - diarized_topics.append(event["data"]) - - if diarized_topics: - logger.info(f"Successfully diarized {len(diarized_topics)} topics") - - # Print sample output - sample_topic = diarized_topics[0] - logger.info("Sample diarized output:") - for i, word in enumerate(sample_topic.transcript.words[:10]): - logger.info(f" Word {i}: '{word.text}' - Speaker {word.speaker}") - else: - logger.warning("No diarization results found in output") - - return events - - except Exception as e: - logger.error(f"Error during processing: {e}") - raise - - -def main(): - if len(sys.argv) < 2: - print("Usage: python test_diarization.py ") - sys.exit(1) - - audio_file = sys.argv[1] - if not Path(audio_file).exists(): - print(f"Error: Audio file '{audio_file}' not found") - sys.exit(1) - - # Run the test - asyncio.run(test_diarization(audio_file)) - - -if __name__ == "__main__": - main() diff --git a/server/reflector/utils/daily.py b/server/reflector/utils/daily.py new file mode 100644 index 00000000..1c3b367c --- /dev/null +++ b/server/reflector/utils/daily.py @@ -0,0 +1,26 @@ +from reflector.utils.string import NonEmptyString + +DailyRoomName = str + + +def extract_base_room_name(daily_room_name: DailyRoomName) -> NonEmptyString: + """ + Extract base room name from Daily.co timestamped room name. + + Daily.co creates rooms with timestamp suffix: {base_name}-YYYYMMDDHHMMSS + This function removes the timestamp to get the original room name. + + Examples: + "daily-20251020193458" → "daily" + "daily-2-20251020193458" → "daily-2" + "my-room-name-20251020193458" → "my-room-name" + + Args: + daily_room_name: Full Daily.co room name with optional timestamp + + Returns: + Base room name without timestamp suffix + """ + base_name = daily_room_name.rsplit("-", 1)[0] + assert base_name, f"Extracted base name is empty from: {daily_room_name}" + return base_name diff --git a/server/reflector/utils/datetime.py b/server/reflector/utils/datetime.py new file mode 100644 index 00000000..d416412f --- /dev/null +++ b/server/reflector/utils/datetime.py @@ -0,0 +1,9 @@ +from datetime import datetime, timezone + + +def parse_datetime_with_timezone(iso_string: str) -> datetime: + """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive).""" + dt = datetime.fromisoformat(iso_string) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt diff --git a/server/reflector/utils/string.py b/server/reflector/utils/string.py new file mode 100644 index 00000000..ae4277c5 --- /dev/null +++ b/server/reflector/utils/string.py @@ -0,0 +1,32 @@ +from typing import Annotated, TypeVar + +from pydantic import Field, TypeAdapter, constr + +NonEmptyStringBase = constr(min_length=1, strip_whitespace=False) +NonEmptyString = Annotated[ + NonEmptyStringBase, + Field(description="A non-empty string", min_length=1), +] +non_empty_string_adapter = TypeAdapter(NonEmptyString) + + +def parse_non_empty_string(s: str, error: str | None = None) -> NonEmptyString: + try: + return non_empty_string_adapter.validate_python(s) + except Exception as e: + raise ValueError(f"{e}: {error}" if error else e) from e + + +def try_parse_non_empty_string(s: str) -> NonEmptyString | None: + if not s: + return None + return parse_non_empty_string(s) + + +T = TypeVar("T", bound=str) + + +def assert_equal[T](s1: T, s2: T) -> T: + if s1 != s2: + raise ValueError(f"assert_equal: {s1} != {s2}") + return s1 diff --git a/server/reflector/utils/url.py b/server/reflector/utils/url.py new file mode 100644 index 00000000..e49a4cb0 --- /dev/null +++ b/server/reflector/utils/url.py @@ -0,0 +1,37 @@ +"""URL manipulation utilities.""" + +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + + +def add_query_param(url: str, key: str, value: str) -> str: + """ + Add or update a query parameter in a URL. + + Properly handles URLs with or without existing query parameters, + preserving fragments and encoding special characters. + + Args: + url: The URL to modify + key: The query parameter name + value: The query parameter value + + Returns: + The URL with the query parameter added or updated + + Examples: + >>> add_query_param("https://example.com/room", "t", "token123") + 'https://example.com/room?t=token123' + + >>> add_query_param("https://example.com/room?existing=param", "t", "token123") + 'https://example.com/room?existing=param&t=token123' + """ + parsed = urlparse(url) + + query_params = parse_qs(parsed.query, keep_blank_values=True) + + query_params[key] = [value] + + new_query = urlencode(query_params, doseq=True) + + new_parsed = parsed._replace(query=new_query) + return urlunparse(new_parsed) diff --git a/server/reflector/video_platforms/__init__.py b/server/reflector/video_platforms/__init__.py new file mode 100644 index 00000000..dcbdc45b --- /dev/null +++ b/server/reflector/video_platforms/__init__.py @@ -0,0 +1,11 @@ +from .base import VideoPlatformClient +from .models import MeetingData, VideoPlatformConfig +from .registry import get_platform_client, register_platform + +__all__ = [ + "VideoPlatformClient", + "VideoPlatformConfig", + "MeetingData", + "get_platform_client", + "register_platform", +] diff --git a/server/reflector/video_platforms/base.py b/server/reflector/video_platforms/base.py new file mode 100644 index 00000000..877114f7 --- /dev/null +++ b/server/reflector/video_platforms/base.py @@ -0,0 +1,55 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import TYPE_CHECKING, Any, Dict, Optional + +from ..schemas.platform import Platform +from ..utils.string import NonEmptyString +from .models import MeetingData, SessionData, VideoPlatformConfig + +if TYPE_CHECKING: + from reflector.db.rooms import Room + +# separator doesn't guarantee there's no more "ROOM_PREFIX_SEPARATOR" strings in room name +ROOM_PREFIX_SEPARATOR = "-" + + +class VideoPlatformClient(ABC): + PLATFORM_NAME: Platform + + def __init__(self, config: VideoPlatformConfig): + self.config = config + + @abstractmethod + async def create_meeting( + self, room_name_prefix: NonEmptyString, end_date: datetime, room: "Room" + ) -> MeetingData: + pass + + @abstractmethod + async def get_room_sessions(self, room_name: str) -> list[SessionData]: + """Get session history for a room.""" + pass + + @abstractmethod + async def delete_room(self, room_name: str) -> bool: + pass + + @abstractmethod + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + pass + + @abstractmethod + def verify_webhook_signature( + self, body: bytes, signature: str, timestamp: Optional[str] = None + ) -> bool: + pass + + def format_recording_config(self, room: "Room") -> Dict[str, Any]: + if room.recording_type == "cloud" and self.config.s3_bucket: + return { + "type": room.recording_type, + "bucket": self.config.s3_bucket, + "region": self.config.s3_region, + "trigger": room.recording_trigger, + } + return {"type": room.recording_type} diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py new file mode 100644 index 00000000..7485cc95 --- /dev/null +++ b/server/reflector/video_platforms/daily.py @@ -0,0 +1,181 @@ +from datetime import datetime + +from reflector.dailyco_api import ( + CreateMeetingTokenRequest, + CreateRoomRequest, + DailyApiClient, + MeetingParticipantsResponse, + MeetingTokenProperties, + RecordingResponse, + RecordingsBucketConfig, + RoomPresenceResponse, + RoomProperties, + verify_webhook_signature, +) +from reflector.db.daily_participant_sessions import ( + daily_participant_sessions_controller, +) +from reflector.db.rooms import Room +from reflector.logger import logger +from reflector.storage import get_dailyco_storage + +from ..schemas.platform import Platform +from ..utils.daily import DailyRoomName +from ..utils.string import NonEmptyString +from .base import ROOM_PREFIX_SEPARATOR, VideoPlatformClient +from .models import MeetingData, RecordingType, SessionData, VideoPlatformConfig + + +class DailyClient(VideoPlatformClient): + PLATFORM_NAME: Platform = "daily" + TIMESTAMP_FORMAT = "%Y%m%d%H%M%S" + RECORDING_NONE: RecordingType = "none" + RECORDING_CLOUD: RecordingType = "cloud" + + def __init__(self, config: VideoPlatformConfig): + super().__init__(config) + self._api_client = DailyApiClient( + api_key=config.api_key, + webhook_secret=config.webhook_secret, + timeout=10.0, + ) + + async def create_meeting( + self, room_name_prefix: NonEmptyString, end_date: datetime, room: Room + ) -> MeetingData: + """ + Daily.co rooms vs meetings: + - We create a NEW Daily.co room for each Reflector meeting + - Daily.co meeting/session starts automatically when first participant joins + - Room auto-deletes after exp time + - Meeting.room_name stores the timestamped Daily.co room name + """ + timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT) + room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{timestamp}" + + properties = RoomProperties( + enable_recording="raw-tracks" + if room.recording_type != self.RECORDING_NONE + else False, + enable_chat=True, + enable_screenshare=True, + start_video_off=False, + start_audio_off=False, + exp=int(end_date.timestamp()), + ) + + # Only configure recordings_bucket if recording is enabled + if room.recording_type != self.RECORDING_NONE: + daily_storage = get_dailyco_storage() + assert daily_storage.bucket_name, "S3 bucket must be configured" + properties.recordings_bucket = RecordingsBucketConfig( + bucket_name=daily_storage.bucket_name, + bucket_region=daily_storage.region, + assume_role_arn=daily_storage.role_credential, + allow_api_access=True, + ) + + request = CreateRoomRequest( + name=room_name, + privacy="private" if room.is_locked else "public", + properties=properties, + ) + + result = await self._api_client.create_room(request) + + return MeetingData( + meeting_id=result.id, + room_name=result.name, + room_url=result.url, + host_room_url=result.url, + platform=self.PLATFORM_NAME, + extra_data=result.model_dump(), + ) + + async def get_room_sessions(self, room_name: str) -> list[SessionData]: + """Get room session history from database (webhook-stored sessions). + + Daily.co doesn't provide historical session API, so we query our database + where participant.joined/left webhooks are stored. + """ + from reflector.db.meetings import meetings_controller # noqa: PLC0415 + + meeting = await meetings_controller.get_by_room_name(room_name) + if not meeting: + return [] + + sessions = await daily_participant_sessions_controller.get_by_meeting( + meeting.id + ) + + return [ + SessionData( + session_id=s.id, + started_at=s.joined_at, + ended_at=s.left_at, + ) + for s in sessions + ] + + async def get_room_presence(self, room_name: str) -> RoomPresenceResponse: + """Get room presence/session data for a Daily.co room.""" + return await self._api_client.get_room_presence(room_name) + + async def get_meeting_participants( + self, meeting_id: str + ) -> MeetingParticipantsResponse: + """Get participant data for a specific Daily.co meeting.""" + return await self._api_client.get_meeting_participants(meeting_id) + + async def get_recording(self, recording_id: str) -> RecordingResponse: + return await self._api_client.get_recording(recording_id) + + async def delete_room(self, room_name: str) -> bool: + """Delete a room (idempotent - succeeds even if room doesn't exist).""" + await self._api_client.delete_room(room_name) + return True + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + return True + + def verify_webhook_signature( + self, body: bytes, signature: str, timestamp: str | None = None + ) -> bool: + """Verify Daily.co webhook signature using dailyco_api module.""" + if not self.config.webhook_secret: + logger.warning("Webhook secret not configured") + return False + + return verify_webhook_signature( + body=body, + signature=signature, + timestamp=timestamp or "", + webhook_secret=self.config.webhook_secret, + ) + + async def create_meeting_token( + self, + room_name: DailyRoomName, + enable_recording: bool, + user_id: str | None = None, + ) -> str: + properties = MeetingTokenProperties( + room_name=room_name, + user_id=user_id, + start_cloud_recording=enable_recording, + enable_recording_ui=not enable_recording, + ) + + request = CreateMeetingTokenRequest(properties=properties) + result = await self._api_client.create_meeting_token(request) + return result.token + + async def close(self): + """Clean up API client resources.""" + await self._api_client.close() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() diff --git a/server/reflector/video_platforms/factory.py b/server/reflector/video_platforms/factory.py new file mode 100644 index 00000000..172d45e7 --- /dev/null +++ b/server/reflector/video_platforms/factory.py @@ -0,0 +1,62 @@ +from typing import Optional + +from reflector.settings import settings +from reflector.storage import get_dailyco_storage, get_whereby_storage + +from ..schemas.platform import WHEREBY_PLATFORM, Platform +from .base import VideoPlatformClient, VideoPlatformConfig +from .registry import get_platform_client + + +def get_platform_config(platform: Platform) -> VideoPlatformConfig: + if platform == WHEREBY_PLATFORM: + if not settings.WHEREBY_API_KEY: + raise ValueError( + "WHEREBY_API_KEY is required when platform='whereby'. " + "Set WHEREBY_API_KEY environment variable." + ) + whereby_storage = get_whereby_storage() + key_id, secret = whereby_storage.key_credentials + return VideoPlatformConfig( + api_key=settings.WHEREBY_API_KEY, + webhook_secret=settings.WHEREBY_WEBHOOK_SECRET or "", + api_url=settings.WHEREBY_API_URL, + s3_bucket=whereby_storage.bucket_name, + s3_region=whereby_storage.region, + aws_access_key_id=key_id, + aws_access_key_secret=secret, + ) + elif platform == "daily": + if not settings.DAILY_API_KEY: + raise ValueError( + "DAILY_API_KEY is required when platform='daily'. " + "Set DAILY_API_KEY environment variable." + ) + if not settings.DAILY_SUBDOMAIN: + raise ValueError( + "DAILY_SUBDOMAIN is required when platform='daily'. " + "Set DAILY_SUBDOMAIN environment variable." + ) + daily_storage = get_dailyco_storage() + return VideoPlatformConfig( + api_key=settings.DAILY_API_KEY, + webhook_secret=settings.DAILY_WEBHOOK_SECRET or "", + subdomain=settings.DAILY_SUBDOMAIN, + s3_bucket=daily_storage.bucket_name, + s3_region=daily_storage.region, + aws_role_arn=daily_storage.role_credential, + ) + else: + raise ValueError(f"Unknown platform: {platform}") + + +def create_platform_client(platform: Platform) -> VideoPlatformClient: + config = get_platform_config(platform) + return get_platform_client(platform, config) + + +def get_platform(room_platform: Optional[Platform] = None) -> Platform: + if room_platform: + return room_platform + + return settings.DEFAULT_VIDEO_PLATFORM diff --git a/server/reflector/video_platforms/models.py b/server/reflector/video_platforms/models.py new file mode 100644 index 00000000..648da251 --- /dev/null +++ b/server/reflector/video_platforms/models.py @@ -0,0 +1,60 @@ +from datetime import datetime +from typing import Any, Dict, Literal, Optional + +from pydantic import BaseModel, Field + +from reflector.schemas.platform import WHEREBY_PLATFORM, Platform +from reflector.utils.string import NonEmptyString + +RecordingType = Literal["none", "local", "cloud"] + + +class SessionData(BaseModel): + """Platform-agnostic session data. + + Represents a participant session in a meeting room, regardless of platform. + Used to determine if a meeting is still active or has ended. + """ + + session_id: NonEmptyString = Field(description="Unique session identifier") + started_at: datetime = Field(description="When session started (UTC)") + ended_at: datetime | None = Field( + description="When session ended (UTC), None if still active" + ) + + +class MeetingData(BaseModel): + platform: Platform + meeting_id: NonEmptyString = Field( + description="Platform-specific meeting identifier" + ) + room_url: NonEmptyString = Field(description="URL for participants to join") + host_room_url: NonEmptyString = Field( + description="URL for hosts (may be same as room_url)" + ) + room_name: NonEmptyString = Field(description="Human-readable room name") + extra_data: Dict[str, Any] = Field(default_factory=dict) + + class Config: + json_schema_extra = { + "example": { + "platform": WHEREBY_PLATFORM, + "meeting_id": "12345678", + "room_url": "https://subdomain.whereby.com/room-20251008120000", + "host_room_url": "https://subdomain.whereby.com/room-20251008120000?roomKey=abc123", + "room_name": "room-20251008120000", + } + } + + +class VideoPlatformConfig(BaseModel): + api_key: str + webhook_secret: str + api_url: Optional[str] = None + subdomain: Optional[str] = None # Whereby/Daily subdomain + s3_bucket: Optional[str] = None + s3_region: Optional[str] = None + # Whereby uses access keys, Daily uses IAM role + aws_access_key_id: Optional[str] = None + aws_access_key_secret: Optional[str] = None + aws_role_arn: Optional[str] = None diff --git a/server/reflector/video_platforms/registry.py b/server/reflector/video_platforms/registry.py new file mode 100644 index 00000000..b4c10697 --- /dev/null +++ b/server/reflector/video_platforms/registry.py @@ -0,0 +1,35 @@ +from typing import Dict, Type + +from ..schemas.platform import DAILY_PLATFORM, WHEREBY_PLATFORM, Platform +from .base import VideoPlatformClient, VideoPlatformConfig + +_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {} + + +def register_platform(name: Platform, client_class: Type[VideoPlatformClient]): + _PLATFORMS[name] = client_class + + +def get_platform_client( + platform: Platform, config: VideoPlatformConfig +) -> VideoPlatformClient: + if platform not in _PLATFORMS: + raise ValueError(f"Unknown video platform: {platform}") + + client_class = _PLATFORMS[platform] + return client_class(config) + + +def get_available_platforms() -> list[Platform]: + return list(_PLATFORMS.keys()) + + +def _register_builtin_platforms(): + from .daily import DailyClient # noqa: PLC0415 + from .whereby import WherebyClient # noqa: PLC0415 + + register_platform(WHEREBY_PLATFORM, WherebyClient) + register_platform(DAILY_PLATFORM, DailyClient) + + +_register_builtin_platforms() diff --git a/server/reflector/video_platforms/whereby.py b/server/reflector/video_platforms/whereby.py new file mode 100644 index 00000000..f4775e89 --- /dev/null +++ b/server/reflector/video_platforms/whereby.py @@ -0,0 +1,173 @@ +import hmac +import json +import re +import time +from datetime import datetime +from hashlib import sha256 +from typing import Optional + +import httpx + +from reflector.db.rooms import Room +from reflector.storage import get_whereby_storage + +from ..schemas.platform import WHEREBY_PLATFORM, Platform +from ..utils.string import NonEmptyString +from .base import VideoPlatformClient +from .models import MeetingData, SessionData, VideoPlatformConfig +from .whereby_utils import whereby_room_name_prefix + + +class WherebyClient(VideoPlatformClient): + PLATFORM_NAME: Platform = WHEREBY_PLATFORM + TIMEOUT = 10 # seconds + MAX_ELAPSED_TIME = 60 * 1000 # 1 minute in milliseconds + + def __init__(self, config: VideoPlatformConfig): + super().__init__(config) + self.headers = { + "Content-Type": "application/json; charset=utf-8", + "Authorization": f"Bearer {config.api_key}", + } + + async def create_meeting( + self, room_name_prefix: NonEmptyString, end_date: datetime, room: Room + ) -> MeetingData: + data = { + "isLocked": room.is_locked, + "roomNamePrefix": whereby_room_name_prefix(room_name_prefix), + "roomNamePattern": "uuid", + "roomMode": room.room_mode, + "endDate": end_date.isoformat(), + "fields": ["hostRoomUrl"], + } + + if room.recording_type == "cloud": + # Get storage config for passing credentials to Whereby API + whereby_storage = get_whereby_storage() + key_id, secret = whereby_storage.key_credentials + data["recording"] = { + "type": room.recording_type, + "destination": { + "provider": "s3", + "bucket": whereby_storage.bucket_name, + "accessKeyId": key_id, + "accessKeySecret": secret, + "fileFormat": "mp4", + }, + "startTrigger": room.recording_trigger, + } + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.config.api_url}/meetings", + headers=self.headers, + json=data, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + result = response.json() + + return MeetingData( + meeting_id=result["meetingId"], + room_name=result["roomName"], + room_url=result["roomUrl"], + host_room_url=result["hostRoomUrl"], + platform=self.PLATFORM_NAME, + extra_data=result, + ) + + async def get_room_sessions(self, room_name: str) -> list[SessionData]: + """Get room session history from Whereby API. + + Whereby API returns: [{"sessionId": "...", "startedAt": "...", "endedAt": "..." | null}, ...] + """ + async with httpx.AsyncClient() as client: + """ + { + "cursor": "text", + "results": [ + { + "roomSessionId": "e2f29530-46ec-4cee-8b27-e565cb5bb2e9", + "roomName": "/room-prefix-793e9ec1-c686-423d-9043-9b7a10c553fd", + "startedAt": "2025-01-01T00:00:00.000Z", + "endedAt": "2025-01-01T01:00:00.000Z", + "totalParticipantMinutes": 124, + "totalRecorderMinutes": 120, + "totalStreamerMinutes": 120, + "totalUniqueParticipants": 4, + "totalUniqueRecorders": 3, + "totalUniqueStreamers": 2 + } + ] + }""" + response = await client.get( + f"{self.config.api_url}/insights/room-sessions?roomName={room_name}", + headers=self.headers, + timeout=self.TIMEOUT, + ) + response.raise_for_status() + results = response.json().get("results", []) + + return [ + SessionData( + session_id=s["roomSessionId"], + started_at=datetime.fromisoformat( + s["startedAt"].replace("Z", "+00:00") + ), + ended_at=datetime.fromisoformat(s["endedAt"].replace("Z", "+00:00")) + if s.get("endedAt") + else None, + ) + for s in results + ] + + async def delete_room(self, room_name: str) -> bool: + return True + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + async with httpx.AsyncClient() as client: + with open(logo_path, "rb") as f: + response = await client.put( + f"{self.config.api_url}/rooms/{room_name}/theme/logo", + headers={ + "Authorization": f"Bearer {self.config.api_key}", + }, + timeout=self.TIMEOUT, + files={"image": f}, + ) + response.raise_for_status() + return True + + def verify_webhook_signature( + self, body: bytes, signature: str, timestamp: Optional[str] = None + ) -> bool: + if not signature: + return False + + matches = re.match(r"t=(.*),v1=(.*)", signature) + if not matches: + return False + + ts, sig = matches.groups() + + current_time = int(time.time() * 1000) + diff_time = current_time - int(ts) * 1000 + if diff_time >= self.MAX_ELAPSED_TIME: + return False + + body_dict = json.loads(body) + signed_payload = f"{ts}.{json.dumps(body_dict, separators=(',', ':'))}" + hmac_obj = hmac.new( + self.config.webhook_secret.encode("utf-8"), + signed_payload.encode("utf-8"), + sha256, + ) + expected_signature = hmac_obj.hexdigest() + + try: + return hmac.compare_digest( + expected_signature.encode("utf-8"), sig.encode("utf-8") + ) + except Exception: + return False diff --git a/server/reflector/video_platforms/whereby_utils.py b/server/reflector/video_platforms/whereby_utils.py new file mode 100644 index 00000000..2724a7b5 --- /dev/null +++ b/server/reflector/video_platforms/whereby_utils.py @@ -0,0 +1,38 @@ +import re +from datetime import datetime + +from reflector.utils.datetime import parse_datetime_with_timezone +from reflector.utils.string import NonEmptyString, parse_non_empty_string +from reflector.video_platforms.base import ROOM_PREFIX_SEPARATOR + + +def parse_whereby_recording_filename( + object_key: NonEmptyString, +) -> (NonEmptyString, datetime): + filename = parse_non_empty_string(object_key.rsplit(".", 1)[0]) + timestamp_pattern = r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)" + match = re.search(timestamp_pattern, filename) + if not match: + raise ValueError(f"No ISO timestamp found in filename: {object_key}") + timestamp_str = match.group(1) + timestamp_start = match.start(1) + room_name_part = filename[:timestamp_start] + if room_name_part.endswith(ROOM_PREFIX_SEPARATOR): + room_name_part = room_name_part[: -len(ROOM_PREFIX_SEPARATOR)] + else: + raise ValueError( + f"room name {room_name_part} doesnt have {ROOM_PREFIX_SEPARATOR} at the end of filename: {object_key}" + ) + + return parse_non_empty_string(room_name_part), parse_datetime_with_timezone( + timestamp_str + ) + + +def whereby_room_name_prefix(room_name_prefix: NonEmptyString) -> NonEmptyString: + return room_name_prefix + ROOM_PREFIX_SEPARATOR + + +# room name comes with "/" from whereby api but lacks "/" e.g. in recording filenames +def room_name_to_whereby_api_room_name(room_name: NonEmptyString) -> NonEmptyString: + return f"/{room_name}" diff --git a/server/reflector/views/daily.py b/server/reflector/views/daily.py new file mode 100644 index 00000000..733c70a3 --- /dev/null +++ b/server/reflector/views/daily.py @@ -0,0 +1,337 @@ +import json +from datetime import datetime, timezone + +from fastapi import APIRouter, HTTPException, Request + +from reflector.dailyco_api import ( + DailyTrack, + DailyWebhookEvent, + extract_room_name, + parse_recording_error, +) +from reflector.db import get_database +from reflector.db.daily_participant_sessions import ( + DailyParticipantSession, + daily_participant_sessions_controller, +) +from reflector.db.meetings import meetings_controller +from reflector.logger import logger as _logger +from reflector.settings import settings +from reflector.video_platforms.factory import create_platform_client +from reflector.worker.process import process_multitrack_recording + +router = APIRouter() + +logger = _logger.bind(platform="daily") + + +@router.post("/webhook") +async def webhook(request: Request): + """Handle Daily webhook events. + + Example webhook payload: + { + "version": "1.0.0", + "type": "recording.ready-to-download", + "id": "rec-rtd-c3df927c-f738-4471-a2b7-066fa7e95a6b-1692124192", + "payload": { + "recording_id": "08fa0b24-9220-44c5-846c-3f116cf8e738", + "room_name": "Xcm97xRZ08b2dePKb78g", + "start_ts": 1692124183, + "status": "finished", + "max_participants": 1, + "duration": 9, + "share_token": "ntDCL5k98Ulq", #gitleaks:allow + "s3_key": "api-test-1j8fizhzd30c/Xcm97xRZ08b2dePKb78g/1692124183028" + }, + "event_ts": 1692124192 + } + + Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook + state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py + """ + body = await request.body() + signature = request.headers.get("X-Webhook-Signature", "") + timestamp = request.headers.get("X-Webhook-Timestamp", "") + + client = create_platform_client("daily") + + if not client.verify_webhook_signature(body, signature, timestamp): + logger.warning( + "Invalid webhook signature", + signature=signature, + timestamp=timestamp, + has_body=bool(body), + ) + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + try: + body_json = json.loads(body) + except json.JSONDecodeError: + raise HTTPException(status_code=422, detail="Invalid JSON") + + if body_json.get("test") == "test": + logger.info("Received Daily webhook test event") + return {"status": "ok"} + + try: + event = DailyWebhookEvent(**body_json) + except Exception as e: + logger.error("Failed to parse webhook event", error=str(e), body=body.decode()) + raise HTTPException(status_code=422, detail="Invalid event format") + + if event.type == "participant.joined": + await _handle_participant_joined(event) + elif event.type == "participant.left": + await _handle_participant_left(event) + elif event.type == "recording.started": + await _handle_recording_started(event) + elif event.type == "recording.ready-to-download": + await _handle_recording_ready(event) + elif event.type == "recording.error": + await _handle_recording_error(event) + else: + logger.warning( + "Unhandled Daily webhook event type", + event_type=event.type, + payload=event.payload, + ) + + return {"status": "ok"} + + +""" +{ + "version": "1.0.0", + "type": "participant.joined", + "id": "ptcpt-join-6497c79b-f326-4942-aef8-c36a29140ad1-1708972279961", + "payload": { + "room": "test", + "user_id": "6497c79b-f326-4942-aef8-c36a29140ad1", + "user_name": "testuser", + "session_id": "0c0d2dda-f21d-4cf9-ab56-86bf3c407ffa", + "joined_at": 1708972279.96, + "will_eject_at": 1708972299.541, + "owner": false, + "permissions": { + "hasPresence": true, + "canSend": true, + "canReceive": { "base": true }, + "canAdmin": false + } + }, + "event_ts": 1708972279.961 +} + +""" + + +async def _handle_participant_joined(event: DailyWebhookEvent): + daily_room_name = extract_room_name(event) + if not daily_room_name: + logger.warning("participant.joined: no room in payload", payload=event.payload) + return + + meeting = await meetings_controller.get_by_room_name(daily_room_name) + if not meeting: + logger.warning( + "participant.joined: meeting not found", room_name=daily_room_name + ) + return + + payload = event.payload + joined_at = datetime.fromtimestamp(payload["joined_at"], tz=timezone.utc) + session_id = f"{meeting.id}:{payload['session_id']}" + + session = DailyParticipantSession( + id=session_id, + meeting_id=meeting.id, + room_id=meeting.room_id, + session_id=payload["session_id"], + user_id=payload.get("user_id", None), + user_name=payload["user_name"], + joined_at=joined_at, + left_at=None, + ) + + # num_clients serves as a projection/cache of active session count for Daily.co + # Both operations must succeed or fail together to maintain consistency + async with get_database().transaction(): + await meetings_controller.increment_num_clients(meeting.id) + await daily_participant_sessions_controller.upsert_joined(session) + + logger.info( + "Participant joined", + meeting_id=meeting.id, + room_name=daily_room_name, + user_id=payload.get("user_id", None), + user_name=payload.get("user_name"), + session_id=session_id, + ) + + +""" +{ + "version": "1.0.0", + "type": "participant.left", + "id": "ptcpt-left-16168c97-f973-4eae-9642-020fe3fda5db-1708972302986", + "payload": { + "room": "test", + "user_id": "16168c97-f973-4eae-9642-020fe3fda5db", + "user_name": "bipol", + "session_id": "0c0d2dda-f21d-4cf9-ab56-86bf3c407ffa", + "joined_at": 1708972291.567, + "will_eject_at": null, + "owner": false, + "permissions": { + "hasPresence": true, + "canSend": true, + "canReceive": { "base": true }, + "canAdmin": false + }, + "duration": 11.419000148773193 + }, + "event_ts": 1708972302.986 +} +""" + + +async def _handle_participant_left(event: DailyWebhookEvent): + room_name = extract_room_name(event) + if not room_name: + logger.warning("participant.left: no room in payload", payload=event.payload) + return + + meeting = await meetings_controller.get_by_room_name(room_name) + if not meeting: + logger.warning("participant.left: meeting not found", room_name=room_name) + return + + payload = event.payload + joined_at = datetime.fromtimestamp(payload["joined_at"], tz=timezone.utc) + left_at = datetime.fromtimestamp(event.event_ts, tz=timezone.utc) + session_id = f"{meeting.id}:{payload['session_id']}" + + session = DailyParticipantSession( + id=session_id, + meeting_id=meeting.id, + room_id=meeting.room_id, + session_id=payload["session_id"], + user_id=payload.get("user_id", None), + user_name=payload["user_name"], + joined_at=joined_at, + left_at=left_at, + ) + + # num_clients serves as a projection/cache of active session count for Daily.co + # Both operations must succeed or fail together to maintain consistency + async with get_database().transaction(): + await meetings_controller.decrement_num_clients(meeting.id) + await daily_participant_sessions_controller.upsert_left(session) + + logger.info( + "Participant left", + meeting_id=meeting.id, + room_name=room_name, + user_id=payload.get("user_id", None), + duration=payload.get("duration"), + session_id=session_id, + ) + + +async def _handle_recording_started(event: DailyWebhookEvent): + room_name = extract_room_name(event) + if not room_name: + logger.warning( + "recording.started: no room_name in payload", payload=event.payload + ) + return + + meeting = await meetings_controller.get_by_room_name(room_name) + if meeting: + logger.info( + "Recording started", + meeting_id=meeting.id, + room_name=room_name, + recording_id=event.payload.get("recording_id"), + platform="daily", + ) + else: + logger.warning("recording.started: meeting not found", room_name=room_name) + + +async def _handle_recording_ready(event: DailyWebhookEvent): + """Handle recording ready for download event. + + Daily.co webhook payload for raw-tracks recordings: + { + "recording_id": "...", + "room_name": "test2-20251009192341", + "tracks": [ + {"type": "audio", "s3Key": "monadical/test2-.../uuid-cam-audio-123.webm", "size": 400000}, + {"type": "video", "s3Key": "monadical/test2-.../uuid-cam-video-456.webm", "size": 30000000} + ] + } + """ + room_name = extract_room_name(event) + recording_id = event.payload.get("recording_id") + tracks_raw = event.payload.get("tracks", []) + + if not room_name or not tracks_raw: + logger.warning( + "recording.ready-to-download: missing room_name or tracks", + room_name=room_name, + has_tracks=bool(tracks_raw), + payload=event.payload, + ) + return + + try: + tracks = [DailyTrack(**t) for t in tracks_raw] + except Exception as e: + logger.error( + "recording.ready-to-download: invalid tracks structure", + error=str(e), + tracks=tracks_raw, + ) + return + + logger.info( + "Recording ready for download", + room_name=room_name, + recording_id=recording_id, + num_tracks=len(tracks), + platform="daily", + ) + + bucket_name = settings.DAILYCO_STORAGE_AWS_BUCKET_NAME + if not bucket_name: + logger.error( + "DAILYCO_STORAGE_AWS_BUCKET_NAME not configured; cannot process Daily recording" + ) + return + + track_keys = [t.s3Key for t in tracks if t.type == "audio"] + + process_multitrack_recording.delay( + bucket_name=bucket_name, + daily_room_name=room_name, + recording_id=recording_id, + track_keys=track_keys, + ) + + +async def _handle_recording_error(event: DailyWebhookEvent): + payload = parse_recording_error(event) + room_name = payload.room_name + + if room_name: + meeting = await meetings_controller.get_by_room_name(room_name) + if meeting: + logger.error( + "Recording error", + meeting_id=meeting.id, + room_name=room_name, + error=payload.error_msg, + platform="daily", + ) diff --git a/server/reflector/views/meetings.py b/server/reflector/views/meetings.py index 2603d875..25987e47 100644 --- a/server/reflector/views/meetings.py +++ b/server/reflector/views/meetings.py @@ -10,6 +10,7 @@ from reflector.db.meetings import ( meeting_consent_controller, meetings_controller, ) +from reflector.db.rooms import rooms_controller router = APIRouter() @@ -41,3 +42,34 @@ async def meeting_audio_consent( updated_consent = await meeting_consent_controller.upsert(consent) return {"status": "success", "consent_id": updated_consent.id} + + +@router.patch("/meetings/{meeting_id}/deactivate") +async def meeting_deactivate( + meeting_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user)], +): + user_id = user["sub"] if user else None + if not user_id: + raise HTTPException(status_code=401, detail="Authentication required") + + meeting = await meetings_controller.get_by_id(meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + if not meeting.is_active: + return {"status": "success", "meeting_id": meeting_id} + + # Only room owner or meeting creator can deactivate + room = await rooms_controller.get_by_id(meeting.room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + if user_id != room.user_id and user_id != meeting.user_id: + raise HTTPException( + status_code=403, detail="Only the room owner can deactivate meetings" + ) + + await meetings_controller.update_meeting(meeting_id, is_active=False) + + return {"status": "success", "meeting_id": meeting_id} diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index d4278e1f..e786b0d9 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -1,33 +1,32 @@ import logging -import sqlite3 from datetime import datetime, timedelta, timezone -from typing import Annotated, Literal, Optional +from enum import Enum +from typing import Annotated, Any, Literal, Optional -import asyncpg.exceptions from fastapi import APIRouter, Depends, HTTPException from fastapi_pagination import Page from fastapi_pagination.ext.databases import apaginate from pydantic import BaseModel +from redis.exceptions import LockError import reflector.auth as auth from reflector.db import get_database +from reflector.db.calendar_events import calendar_events_controller from reflector.db.meetings import meetings_controller from reflector.db.rooms import rooms_controller +from reflector.redis_cache import RedisAsyncLock +from reflector.schemas.platform import Platform +from reflector.services.ics_sync import ics_sync_service from reflector.settings import settings -from reflector.whereby import create_meeting, upload_logo +from reflector.utils.url import add_query_param +from reflector.video_platforms.factory import ( + create_platform_client, + get_platform, +) +from reflector.worker.webhook import test_webhook logger = logging.getLogger(__name__) -router = APIRouter() - - -def parse_datetime_with_timezone(iso_string: str) -> datetime: - """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive).""" - dt = datetime.fromisoformat(iso_string) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - class Room(BaseModel): id: str @@ -42,16 +41,40 @@ class Room(BaseModel): recording_type: str recording_trigger: str is_shared: bool + ics_url: Optional[str] = None + ics_fetch_interval: int = 300 + ics_enabled: bool = False + ics_last_sync: Optional[datetime] = None + ics_last_etag: Optional[str] = None + platform: Platform + + +class RoomDetails(Room): + webhook_url: str | None + webhook_secret: str | None class Meeting(BaseModel): id: str room_name: str room_url: str + # TODO it's not always present, | None host_room_url: str start_date: datetime end_date: datetime + user_id: str | None = None + room_id: str | None = None + is_locked: bool = False + room_mode: Literal["normal", "group"] = "normal" recording_type: Literal["none", "local", "cloud"] = "cloud" + recording_trigger: Literal[ + "none", "prompt", "automatic", "automatic-2nd-participant" + ] = "automatic-2nd-participant" + num_clients: int = 0 + is_active: bool = True + calendar_event_id: str | None = None + calendar_metadata: dict[str, Any] | None = None + platform: Platform class CreateRoom(BaseModel): @@ -64,47 +87,159 @@ class CreateRoom(BaseModel): recording_type: str recording_trigger: str is_shared: bool + webhook_url: str + webhook_secret: str + ics_url: Optional[str] = None + ics_fetch_interval: int = 300 + ics_enabled: bool = False + platform: Optional[Platform] = None class UpdateRoom(BaseModel): - name: str - zulip_auto_post: bool - zulip_stream: str - zulip_topic: str - is_locked: bool - room_mode: str - recording_type: str - recording_trigger: str - is_shared: bool + name: Optional[str] = None + zulip_auto_post: Optional[bool] = None + zulip_stream: Optional[str] = None + zulip_topic: Optional[str] = None + is_locked: Optional[bool] = None + room_mode: Optional[str] = None + recording_type: Optional[str] = None + recording_trigger: Optional[str] = None + is_shared: Optional[bool] = None + webhook_url: Optional[str] = None + webhook_secret: Optional[str] = None + ics_url: Optional[str] = None + ics_fetch_interval: Optional[int] = None + ics_enabled: Optional[bool] = None + platform: Optional[Platform] = None + + +class CreateRoomMeeting(BaseModel): + allow_duplicated: Optional[bool] = False class DeletionStatus(BaseModel): status: str -@router.get("/rooms", response_model=Page[Room]) +class WebhookTestResult(BaseModel): + success: bool + message: str = "" + error: str = "" + status_code: int | None = None + response_preview: str | None = None + + +class ICSStatus(BaseModel): + status: Literal["enabled", "disabled"] + last_sync: Optional[datetime] = None + next_sync: Optional[datetime] = None + last_etag: Optional[str] = None + events_count: int = 0 + + +class SyncStatus(str, Enum): + success = "success" + unchanged = "unchanged" + error = "error" + skipped = "skipped" + + +class ICSSyncResult(BaseModel): + status: SyncStatus + hash: Optional[str] = None + events_found: int = 0 + total_events: int = 0 + events_created: int = 0 + events_updated: int = 0 + events_deleted: int = 0 + error: Optional[str] = None + reason: Optional[str] = None + + +class CalendarEventResponse(BaseModel): + id: str + room_id: str + ics_uid: str + title: Optional[str] = None + description: Optional[str] = None + start_time: datetime + end_time: datetime + attendees: Optional[list[dict]] = None + location: Optional[str] = None + last_synced: datetime + created_at: datetime + updated_at: datetime + + +router = APIRouter() + + +@router.get("/rooms", response_model=Page[RoomDetails]) async def rooms_list( user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], -) -> list[Room]: +) -> list[RoomDetails]: if not user and not settings.PUBLIC_MODE: raise HTTPException(status_code=401, detail="Not authenticated") user_id = user["sub"] if user else None - return await apaginate( + paginated = await apaginate( get_database(), await rooms_controller.get_all( user_id=user_id, order_by="-created_at", return_query=True ), ) + for room in paginated.items: + room.platform = get_platform(room.platform) + + return paginated + + +@router.get("/rooms/{room_id}", response_model=RoomDetails) +async def rooms_get( + room_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + if not room.is_shared and (user_id is None or room.user_id != user_id): + raise HTTPException(status_code=403, detail="Room access denied") + room.platform = get_platform(room.platform) + return room + + +@router.get("/rooms/name/{room_name}", response_model=RoomDetails) +async def rooms_get_by_name( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + room_dict = room.__dict__.copy() + if user_id == room.user_id: + room_dict["webhook_url"] = getattr(room, "webhook_url", None) + room_dict["webhook_secret"] = getattr(room, "webhook_secret", None) + else: + room_dict["webhook_url"] = None + room_dict["webhook_secret"] = None + + room_dict["platform"] = get_platform(room.platform) + + return RoomDetails(**room_dict) + @router.post("/rooms", response_model=Room) async def rooms_create( room: CreateRoom, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] return await rooms_controller.add( name=room.name, @@ -117,33 +252,44 @@ async def rooms_create( recording_type=room.recording_type, recording_trigger=room.recording_trigger, is_shared=room.is_shared, + webhook_url=room.webhook_url, + webhook_secret=room.webhook_secret, + ics_url=room.ics_url, + ics_fetch_interval=room.ics_fetch_interval, + ics_enabled=room.ics_enabled, + platform=room.platform, ) -@router.patch("/rooms/{room_id}", response_model=Room) +@router.patch("/rooms/{room_id}", response_model=RoomDetails) async def rooms_update( room_id: str, info: UpdateRoom, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id) if not room: raise HTTPException(status_code=404, detail="Room not found") + if room.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") values = info.dict(exclude_unset=True) await rooms_controller.update(room, values) + room.platform = get_platform(room.platform) return room @router.delete("/rooms/{room_id}", response_model=DeletionStatus) async def rooms_delete( room_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None - room = await rooms_controller.get_by_id(room_id, user_id=user_id) + user_id = user["sub"] + room = await rooms_controller.get_by_id(room_id) if not room: raise HTTPException(status_code=404, detail="Room not found") + if room.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") await rooms_controller.remove_by_id(room.id, user_id=user_id) return DeletionStatus(status="ok") @@ -151,6 +297,7 @@ async def rooms_delete( @router.post("/rooms/{room_name}/meeting", response_model=Meeting) async def rooms_create_meeting( room_name: str, + info: CreateRoomMeeting, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): user_id = user["sub"] if user else None @@ -158,52 +305,271 @@ async def rooms_create_meeting( if not room: raise HTTPException(status_code=404, detail="Room not found") - current_time = datetime.now(timezone.utc) - meeting = await meetings_controller.get_active(room=room, current_time=current_time) + try: + async with RedisAsyncLock( + f"create_meeting:{room_name}", + timeout=30, + extend_interval=10, + blocking_timeout=5.0, + ) as lock: + current_time = datetime.now(timezone.utc) - if meeting is None: - end_date = current_time + timedelta(hours=8) + meeting = None + if not info.allow_duplicated: + meeting = await meetings_controller.get_active( + room=room, current_time=current_time + ) - whereby_meeting = await create_meeting("", end_date=end_date, room=room) - await upload_logo(whereby_meeting["roomName"], "./images/logo.png") - - # Now try to save to database - try: - meeting = await meetings_controller.create( - id=whereby_meeting["meetingId"], - room_name=whereby_meeting["roomName"], - room_url=whereby_meeting["roomUrl"], - host_room_url=whereby_meeting["hostRoomUrl"], - start_date=parse_datetime_with_timezone(whereby_meeting["startDate"]), - end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]), - user_id=user_id, - room=room, - ) - except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError): - # Another request already created a meeting for this room - # Log this race condition occurrence - logger.info( - "Race condition detected for room %s - fetching existing meeting", - room.name, - ) - logger.warning( - "Whereby meeting %s was created but not used (resource leak) for room %s", - whereby_meeting["meetingId"], - room.name, - ) - - # Fetch the meeting that was created by the other request - meeting = await meetings_controller.get_active( - room=room, current_time=current_time - ) if meeting is None: - # Edge case: meeting was created but expired/deleted between checks - logger.error( - "Meeting disappeared after race condition for room %s", room.name + end_date = current_time + timedelta(hours=8) + + platform = get_platform(room.platform) + client = create_platform_client(platform) + + meeting_data = await client.create_meeting( + room.name, end_date=end_date, room=room ) - raise HTTPException( - status_code=503, detail="Unable to join meeting - please try again" + + await client.upload_logo(meeting_data.room_name, "./images/logo.png") + + meeting = await meetings_controller.create( + id=meeting_data.meeting_id, + room_name=meeting_data.room_name, + room_url=meeting_data.room_url, + host_room_url=meeting_data.host_room_url, + start_date=current_time, + end_date=end_date, + room=room, ) + except LockError: + logger.warning("Failed to acquire lock for room %s within timeout", room_name) + raise HTTPException( + status_code=503, detail="Meeting creation in progress, please try again" + ) + + if meeting.platform == "daily" and room.recording_trigger != "none": + client = create_platform_client(meeting.platform) + token = await client.create_meeting_token( + meeting.room_name, + enable_recording=True, + user_id=user_id, + ) + meeting = meeting.model_copy() + meeting.room_url = add_query_param(meeting.room_url, "t", token) + if meeting.host_room_url: + meeting.host_room_url = add_query_param(meeting.host_room_url, "t", token) + + if user_id != room.user_id: + meeting.host_room_url = "" + + return meeting + + +@router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult) +async def rooms_test_webhook( + room_id: str, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + """Test webhook configuration by sending a sample payload.""" + user_id = user["sub"] + + room = await rooms_controller.get_by_id(room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + if room.user_id != user_id: + raise HTTPException( + status_code=403, detail="Not authorized to test this room's webhook" + ) + + result = await test_webhook(room_id) + return WebhookTestResult(**result) + + +@router.post("/rooms/{room_name}/ics/sync", response_model=ICSSyncResult) +async def rooms_sync_ics( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + if user_id != room.user_id: + raise HTTPException( + status_code=403, detail="Only room owner can trigger ICS sync" + ) + + if not room.ics_enabled or not room.ics_url: + raise HTTPException(status_code=400, detail="ICS not configured for this room") + + result = await ics_sync_service.sync_room_calendar(room) + + if result["status"] == "error": + raise HTTPException( + status_code=500, detail=result.get("error", "Unknown error") + ) + + return ICSSyncResult(**result) + + +@router.get("/rooms/{room_name}/ics/status", response_model=ICSStatus) +async def rooms_ics_status( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + if user_id != room.user_id: + raise HTTPException( + status_code=403, detail="Only room owner can view ICS status" + ) + + next_sync = None + if room.ics_enabled and room.ics_last_sync: + next_sync = room.ics_last_sync + timedelta(seconds=room.ics_fetch_interval) + + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + + return ICSStatus( + status="enabled" if room.ics_enabled else "disabled", + last_sync=room.ics_last_sync, + next_sync=next_sync, + last_etag=room.ics_last_etag, + events_count=len(events), + ) + + +@router.get("/rooms/{room_name}/meetings", response_model=list[CalendarEventResponse]) +async def rooms_list_meetings( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + + if user_id != room.user_id: + for event in events: + event.description = None + event.attendees = None + + return events + + +@router.get( + "/rooms/{room_name}/meetings/upcoming", response_model=list[CalendarEventResponse] +) +async def rooms_list_upcoming_meetings( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + minutes_ahead: int = 120, +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + events = await calendar_events_controller.get_upcoming( + room.id, minutes_ahead=minutes_ahead + ) + + if user_id != room.user_id: + for event in events: + event.description = None + event.attendees = None + + return events + + +@router.get("/rooms/{room_name}/meetings/active", response_model=list[Meeting]) +async def rooms_list_active_meetings( + room_name: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + current_time = datetime.now(timezone.utc) + meetings = await meetings_controller.get_all_active_for_room( + room=room, current_time=current_time + ) + + effective_platform = get_platform(room.platform) + for meeting in meetings: + meeting.platform = effective_platform + + if user_id != room.user_id: + for meeting in meetings: + meeting.host_room_url = "" + + return meetings + + +@router.get("/rooms/{room_name}/meetings/{meeting_id}", response_model=Meeting) +async def rooms_get_meeting( + room_name: str, + meeting_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + """Get a single meeting by ID within a specific room.""" + user_id = user["sub"] if user else None + + room = await rooms_controller.get_by_name(room_name) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + meeting = await meetings_controller.get_by_id(meeting_id, room=room) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + if user_id != room.user_id and not room.is_shared: + meeting.host_room_url = "" + + return meeting + + +@router.post("/rooms/{room_name}/meetings/{meeting_id}/join", response_model=Meeting) +async def rooms_join_meeting( + room_name: str, + meeting_id: str, + user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], +): + user_id = user["sub"] if user else None + room = await rooms_controller.get_by_name(room_name) + + if not room: + raise HTTPException(status_code=404, detail="Room not found") + + meeting = await meetings_controller.get_by_id(meeting_id, room=room) + + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + + if not meeting.is_active: + raise HTTPException(status_code=400, detail="Meeting is not active") + + current_time = datetime.now(timezone.utc) + if meeting.end_date <= current_time: + raise HTTPException(status_code=400, detail="Meeting has ended") if user_id != room.user_id: meeting.host_room_url = "" diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 594dd711..37e806cb 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -5,12 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query from fastapi_pagination import Page from fastapi_pagination.ext.databases import apaginate from jose import jwt -from pydantic import BaseModel, Field, field_serializer +from pydantic import AwareDatetime, BaseModel, Field, constr, field_serializer import reflector.auth as auth from reflector.db import get_database -from reflector.db.meetings import meetings_controller -from reflector.db.rooms import rooms_controller from reflector.db.search import ( DEFAULT_SEARCH_LIMIT, SearchLimit, @@ -19,20 +17,22 @@ from reflector.db.search import ( SearchOffsetBase, SearchParameters, SearchQuery, - SearchQueryBase, SearchResult, SearchTotal, search_controller, + search_query_adapter, ) from reflector.db.transcripts import ( SourceKind, TranscriptParticipant, + TranscriptStatus, TranscriptTopic, transcripts_controller, ) from reflector.processors.types import Transcript as ProcessorTranscript from reflector.processors.types import Word from reflector.settings import settings +from reflector.ws_manager import get_ws_manager from reflector.zulip import ( InvalidMessageError, get_zulip_message, @@ -63,7 +63,7 @@ class GetTranscriptMinimal(BaseModel): id: str user_id: str | None name: str - status: str + status: TranscriptStatus locked: bool duration: float title: str | None @@ -96,6 +96,7 @@ class CreateTranscript(BaseModel): name: str source_language: str = Field("en") target_language: str = Field("en") + source_kind: SourceKind | None = None class UpdateTranscript(BaseModel): @@ -114,17 +115,44 @@ class DeletionStatus(BaseModel): status: str -SearchQueryParam = Annotated[SearchQueryBase, Query(description="Search query text")] +SearchQueryParamBase = constr(min_length=0, strip_whitespace=True) +SearchQueryParam = Annotated[ + SearchQueryParamBase, Query(description="Search query text") +] + + +# http and api standards accept "q="; we would like to handle it as the absence of query, not as "empty string query" +def parse_search_query_param(q: SearchQueryParam) -> SearchQuery | None: + if q == "": + return None + return search_query_adapter.validate_python(q) + + SearchLimitParam = Annotated[SearchLimitBase, Query(description="Results per page")] SearchOffsetParam = Annotated[ SearchOffsetBase, Query(description="Number of results to skip") ] +SearchFromDatetimeParam = Annotated[ + AwareDatetime | None, + Query( + alias="from", + description="Filter transcripts created on or after this datetime (ISO 8601 with timezone)", + ), +] +SearchToDatetimeParam = Annotated[ + AwareDatetime | None, + Query( + alias="to", + description="Filter transcripts created on or before this datetime (ISO 8601 with timezone)", + ), +] + class SearchResponse(BaseModel): results: list[SearchResult] total: SearchTotal - query: SearchQuery + query: SearchQuery | None = None limit: SearchLimit offset: SearchOffset @@ -161,25 +189,32 @@ async def transcripts_search( offset: SearchOffsetParam = 0, room_id: Optional[str] = None, source_kind: Optional[SourceKind] = None, + from_datetime: SearchFromDatetimeParam = None, + to_datetime: SearchToDatetimeParam = None, user: Annotated[ Optional[auth.UserInfo], Depends(auth.current_user_optional) ] = None, ): - """ - Full-text search across transcript titles and content. - """ + """Full-text search across transcript titles and content.""" if not user and not settings.PUBLIC_MODE: raise HTTPException(status_code=401, detail="Not authenticated") user_id = user["sub"] if user else None + if from_datetime and to_datetime and from_datetime > to_datetime: + raise HTTPException( + status_code=400, detail="'from' must be less than or equal to 'to'" + ) + search_params = SearchParameters( - query_text=q, + query_text=parse_search_query_param(q), limit=limit, offset=offset, user_id=user_id, room_id=room_id, source_kind=source_kind, + from_datetime=from_datetime, + to_datetime=to_datetime, ) results, total = await search_controller.search_transcripts(search_params) @@ -199,14 +234,22 @@ async def transcripts_create( user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], ): user_id = user["sub"] if user else None - return await transcripts_controller.add( + transcript = await transcripts_controller.add( info.name, - source_kind=SourceKind.LIVE, + source_kind=info.source_kind or SourceKind.LIVE, source_language=info.source_language, target_language=info.target_language, user_id=user_id, ) + if user_id: + await get_ws_manager().send_json( + room_id=f"user:{user_id}", + message={"event": "TRANSCRIPT_CREATED", "data": {"id": transcript.id}}, + ) + + return transcript + # ============================================================== # Single transcript @@ -330,14 +373,14 @@ async def transcript_get( async def transcript_update( transcript_id: str, info: UpdateTranscript, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) - if not transcript: - raise HTTPException(status_code=404, detail="Transcript not found") + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") values = info.dict(exclude_unset=True) updated_transcript = await transcripts_controller.update(transcript, values) return updated_transcript @@ -346,20 +389,20 @@ async def transcript_update( @router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus) async def transcript_delete( transcript_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id(transcript_id) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") - - if transcript.meeting_id: - meeting = await meetings_controller.get_by_id(transcript.meeting_id) - room = await rooms_controller.get_by_id(meeting.room_id) - if room.is_shared: - user_id = None + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") await transcripts_controller.remove_by_id(transcript.id, user_id=user_id) + await get_ws_manager().send_json( + room_id=f"user:{user_id}", + message={"event": "TRANSCRIPT_DELETED", "data": {"id": transcript.id}}, + ) return DeletionStatus(status="ok") @@ -431,15 +474,16 @@ async def transcript_post_to_zulip( stream: str, topic: str, include_topics: bool, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ): - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") - + if not transcripts_controller.user_can_mutate(transcript, user_id): + raise HTTPException(status_code=403, detail="Not authorized") content = get_zulip_message(transcript, include_topics) message_updated = False diff --git a/server/reflector/views/transcripts_participants.py b/server/reflector/views/transcripts_participants.py index 6b407c69..eb314eff 100644 --- a/server/reflector/views/transcripts_participants.py +++ b/server/reflector/views/transcripts_participants.py @@ -56,12 +56,14 @@ async def transcript_get_participants( async def transcript_add_participant( transcript_id: str, participant: CreateParticipant, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> Participant: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") # ensure the speaker is unique if participant.speaker is not None and transcript.participants is not None: @@ -101,12 +103,14 @@ async def transcript_update_participant( transcript_id: str, participant_id: str, participant: UpdateParticipant, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> Participant: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") # ensure the speaker is unique for p in transcript.participants: @@ -138,11 +142,13 @@ async def transcript_update_participant( async def transcript_delete_participant( transcript_id: str, participant_id: str, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> DeletionStatus: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") await transcripts_controller.delete_participant(transcript, participant_id) return DeletionStatus(status="ok") diff --git a/server/reflector/views/transcripts_process.py b/server/reflector/views/transcripts_process.py index 8f6d3ab6..cee1e10d 100644 --- a/server/reflector/views/transcripts_process.py +++ b/server/reflector/views/transcripts_process.py @@ -5,8 +5,12 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel import reflector.auth as auth +from reflector.db.recordings import recordings_controller from reflector.db.transcripts import transcripts_controller -from reflector.pipelines.main_live_pipeline import task_pipeline_process +from reflector.pipelines.main_file_pipeline import task_pipeline_file_process +from reflector.pipelines.main_multitrack_pipeline import ( + task_pipeline_multitrack_process, +) router = APIRouter() @@ -33,14 +37,44 @@ async def transcript_process( status_code=400, detail="Recording is not ready for processing" ) + # avoid duplicate scheduling for either pipeline if task_is_scheduled_or_active( - "reflector.pipelines.main_live_pipeline.task_pipeline_process", + "reflector.pipelines.main_file_pipeline.task_pipeline_file_process", + transcript_id=transcript_id, + ) or task_is_scheduled_or_active( + "reflector.pipelines.main_multitrack_pipeline.task_pipeline_multitrack_process", transcript_id=transcript_id, ): return ProcessStatus(status="already running") - # schedule a background task process the file - task_pipeline_process.delay(transcript_id=transcript_id) + # Determine processing mode strictly from DB to avoid S3 scans + bucket_name = None + track_keys: list[str] = [] + + if transcript.recording_id: + recording = await recordings_controller.get_by_id(transcript.recording_id) + if recording: + bucket_name = recording.bucket_name + track_keys = recording.track_keys + if track_keys is not None and len(track_keys) == 0: + raise HTTPException( + status_code=500, + detail="No track keys found, must be either > 0 or None", + ) + if track_keys is not None and not bucket_name: + raise HTTPException( + status_code=500, detail="Bucket name must be specified" + ) + + if track_keys: + task_pipeline_multitrack_process.delay( + transcript_id=transcript_id, + bucket_name=bucket_name, + track_keys=track_keys, + ) + else: + # Default single-file pipeline + task_pipeline_file_process.delay(transcript_id=transcript_id) return ProcessStatus(status="ok") diff --git a/server/reflector/views/transcripts_speaker.py b/server/reflector/views/transcripts_speaker.py index e027bd44..787e554a 100644 --- a/server/reflector/views/transcripts_speaker.py +++ b/server/reflector/views/transcripts_speaker.py @@ -35,12 +35,14 @@ class SpeakerMerge(BaseModel): async def transcript_assign_speaker( transcript_id: str, assignment: SpeakerAssignment, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> SpeakerAssignmentStatus: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") @@ -113,12 +115,14 @@ async def transcript_assign_speaker( async def transcript_merge_speaker( transcript_id: str, merge: SpeakerMerge, - user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Annotated[auth.UserInfo, Depends(auth.current_user)], ) -> SpeakerAssignmentStatus: - user_id = user["sub"] if user else None + user_id = user["sub"] transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + if transcript.user_id is not None and transcript.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized") if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") diff --git a/server/reflector/views/transcripts_upload.py b/server/reflector/views/transcripts_upload.py index 18e75dac..8efbc274 100644 --- a/server/reflector/views/transcripts_upload.py +++ b/server/reflector/views/transcripts_upload.py @@ -6,7 +6,7 @@ from pydantic import BaseModel import reflector.auth as auth from reflector.db.transcripts import transcripts_controller -from reflector.pipelines.main_live_pipeline import task_pipeline_process +from reflector.pipelines.main_file_pipeline import task_pipeline_file_process router = APIRouter() @@ -92,6 +92,6 @@ async def transcript_record_upload( await transcripts_controller.update(transcript, {"status": "uploaded"}) # launch a background task to process the file - task_pipeline_process.delay(transcript_id=transcript_id) + task_pipeline_file_process.delay(transcript_id=transcript_id) return UploadStatus(status="ok") diff --git a/server/reflector/views/transcripts_websocket.py b/server/reflector/views/transcripts_websocket.py index c78e418c..ccb7d7ff 100644 --- a/server/reflector/views/transcripts_websocket.py +++ b/server/reflector/views/transcripts_websocket.py @@ -4,8 +4,11 @@ Transcripts websocket API """ -from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect + +import reflector.auth as auth from reflector.db.transcripts import transcripts_controller from reflector.ws_manager import get_ws_manager @@ -21,10 +24,12 @@ async def transcript_get_websocket_events(transcript_id: str): async def transcript_events_websocket( transcript_id: str, websocket: WebSocket, - # user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + user: Optional[auth.UserInfo] = Depends(auth.current_user_optional), ): - # user_id = user["sub"] if user else None - transcript = await transcripts_controller.get_by_id(transcript_id) + user_id = user["sub"] if user else None + transcript = await transcripts_controller.get_by_id_for_http( + transcript_id, user_id=user_id + ) if not transcript: raise HTTPException(status_code=404, detail="Transcript not found") diff --git a/server/reflector/views/user.py b/server/reflector/views/user.py index fa68f79c..e62f43f7 100644 --- a/server/reflector/views/user.py +++ b/server/reflector/views/user.py @@ -11,7 +11,6 @@ router = APIRouter() class UserInfo(BaseModel): sub: str email: Optional[str] - email_verified: Optional[bool] @router.get("/me") diff --git a/server/reflector/views/user_api_keys.py b/server/reflector/views/user_api_keys.py new file mode 100644 index 00000000..f83768af --- /dev/null +++ b/server/reflector/views/user_api_keys.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Annotated + +import structlog +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +import reflector.auth as auth +from reflector.db.user_api_keys import user_api_keys_controller +from reflector.utils.string import NonEmptyString + +router = APIRouter() +logger = structlog.get_logger(__name__) + + +class CreateApiKeyRequest(BaseModel): + name: NonEmptyString | None = None + + +class ApiKeyResponse(BaseModel): + id: NonEmptyString + user_id: NonEmptyString + name: NonEmptyString | None + created_at: datetime + + +class CreateApiKeyResponse(ApiKeyResponse): + key: NonEmptyString + + +@router.post("/user/api-keys", response_model=CreateApiKeyResponse) +async def create_api_key( + req: CreateApiKeyRequest, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + api_key_model, plaintext = await user_api_keys_controller.create_key( + user_id=user["sub"], + name=req.name, + ) + return CreateApiKeyResponse( + **api_key_model.model_dump(), + key=plaintext, + ) + + +@router.get("/user/api-keys", response_model=list[ApiKeyResponse]) +async def list_api_keys( + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + api_keys = await user_api_keys_controller.list_by_user_id(user["sub"]) + return [ApiKeyResponse(**k.model_dump()) for k in api_keys] + + +@router.delete("/user/api-keys/{key_id}") +async def delete_api_key( + key_id: NonEmptyString, + user: Annotated[auth.UserInfo, Depends(auth.current_user)], +): + deleted = await user_api_keys_controller.delete_key(key_id, user["sub"]) + if not deleted: + raise HTTPException(status_code=404) + return {"status": "ok"} diff --git a/server/reflector/views/user_websocket.py b/server/reflector/views/user_websocket.py new file mode 100644 index 00000000..26d3c8ac --- /dev/null +++ b/server/reflector/views/user_websocket.py @@ -0,0 +1,53 @@ +from typing import Optional + +from fastapi import APIRouter, WebSocket + +from reflector.auth.auth_jwt import JWTAuth # type: ignore +from reflector.ws_manager import get_ws_manager + +router = APIRouter() + +# Close code for unauthorized WebSocket connections +UNAUTHORISED = 4401 + + +@router.websocket("/events") +async def user_events_websocket(websocket: WebSocket): + # Browser can't send Authorization header for WS; use subprotocol: ["bearer", token] + raw_subprotocol = websocket.headers.get("sec-websocket-protocol") or "" + parts = [p.strip() for p in raw_subprotocol.split(",") if p.strip()] + token: Optional[str] = None + negotiated_subprotocol: Optional[str] = None + if len(parts) >= 2 and parts[0].lower() == "bearer": + negotiated_subprotocol = "bearer" + token = parts[1] + + user_id: Optional[str] = None + if not token: + await websocket.close(code=UNAUTHORISED) + return + + try: + payload = JWTAuth().verify_token(token) + user_id = payload.get("sub") + except Exception: + await websocket.close(code=UNAUTHORISED) + return + + if not user_id: + await websocket.close(code=UNAUTHORISED) + return + + room_id = f"user:{user_id}" + ws_manager = get_ws_manager() + + await ws_manager.add_user_to_room( + room_id, websocket, subprotocol=negotiated_subprotocol + ) + + try: + while True: + await websocket.receive() + finally: + if room_id: + await ws_manager.remove_user_from_room(room_id, websocket) diff --git a/server/reflector/views/whereby.py b/server/reflector/views/whereby.py index c1682621..d12b0a9f 100644 --- a/server/reflector/views/whereby.py +++ b/server/reflector/views/whereby.py @@ -68,8 +68,7 @@ async def whereby_webhook(event: WherebyWebhookEvent, request: Request): raise HTTPException(status_code=404, detail="Meeting not found") if event.type in ["room.client.joined", "room.client.left"]: - await meetings_controller.update_meeting( - meeting.id, num_clients=event.data["numClients"] - ) + update_data = {"num_clients": event.data["numClients"]} + await meetings_controller.update_meeting(meeting.id, **update_data) return {"status": "ok"} diff --git a/server/reflector/whereby.py b/server/reflector/whereby.py deleted file mode 100644 index deaa5274..00000000 --- a/server/reflector/whereby.py +++ /dev/null @@ -1,69 +0,0 @@ -from datetime import datetime - -import httpx - -from reflector.db.rooms import Room -from reflector.settings import settings - -HEADERS = { - "Content-Type": "application/json; charset=utf-8", - "Authorization": f"Bearer {settings.WHEREBY_API_KEY}", -} -TIMEOUT = 10 # seconds - - -async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room): - data = { - "isLocked": room.is_locked, - "roomNamePrefix": room_name_prefix, - "roomNamePattern": "uuid", - "roomMode": room.room_mode, - "endDate": end_date.isoformat(), - "recording": { - "type": room.recording_type, - "destination": { - "provider": "s3", - "bucket": settings.RECORDING_STORAGE_AWS_BUCKET_NAME, - "accessKeyId": settings.AWS_WHEREBY_ACCESS_KEY_ID, - "accessKeySecret": settings.AWS_WHEREBY_ACCESS_KEY_SECRET, - "fileFormat": "mp4", - }, - "startTrigger": room.recording_trigger, - }, - "fields": ["hostRoomUrl"], - } - - async with httpx.AsyncClient() as client: - response = await client.post( - f"{settings.WHEREBY_API_URL}/meetings", - headers=HEADERS, - json=data, - timeout=TIMEOUT, - ) - response.raise_for_status() - return response.json() - - -async def get_room_sessions(room_name: str): - async with httpx.AsyncClient() as client: - response = await client.get( - f"{settings.WHEREBY_API_URL}/insights/room-sessions?roomName={room_name}", - headers=HEADERS, - timeout=TIMEOUT, - ) - response.raise_for_status() - return response.json() - - -async def upload_logo(room_name: str, logo_path: str): - async with httpx.AsyncClient() as client: - with open(logo_path, "rb") as f: - response = await client.put( - f"{settings.WHEREBY_API_URL}/rooms{room_name}/theme/logo", - headers={ - "Authorization": f"Bearer {settings.WHEREBY_API_KEY}", - }, - timeout=TIMEOUT, - files={"image": f}, - ) - response.raise_for_status() diff --git a/server/reflector/worker/app.py b/server/reflector/worker/app.py index 7e888f41..3c7795a2 100644 --- a/server/reflector/worker/app.py +++ b/server/reflector/worker/app.py @@ -19,6 +19,8 @@ else: "reflector.pipelines.main_live_pipeline", "reflector.worker.healthcheck", "reflector.worker.process", + "reflector.worker.cleanup", + "reflector.worker.ics_sync", ] ) @@ -36,8 +38,26 @@ else: "task": "reflector.worker.process.reprocess_failed_recordings", "schedule": crontab(hour=5, minute=0), # Midnight EST }, + "sync_all_ics_calendars": { + "task": "reflector.worker.ics_sync.sync_all_ics_calendars", + "schedule": 60.0, # Run every minute to check which rooms need sync + }, + "create_upcoming_meetings": { + "task": "reflector.worker.ics_sync.create_upcoming_meetings", + "schedule": 30.0, # Run every 30 seconds to create upcoming meetings + }, } + if settings.PUBLIC_MODE: + app.conf.beat_schedule["cleanup_old_public_data"] = { + "task": "reflector.worker.cleanup.cleanup_old_public_data_task", + "schedule": crontab(hour=3, minute=0), + } + logger.info( + "Public mode cleanup enabled", + retention_days=settings.PUBLIC_DATA_RETENTION_DAYS, + ) + if settings.HEALTHCHECK_URL: app.conf.beat_schedule["healthcheck_ping"] = { "task": "reflector.worker.healthcheck.healthcheck_ping", diff --git a/server/reflector/worker/cleanup.py b/server/reflector/worker/cleanup.py new file mode 100644 index 00000000..43559e64 --- /dev/null +++ b/server/reflector/worker/cleanup.py @@ -0,0 +1,155 @@ +""" +Main task for cleanup old public data. + +Deletes old anonymous transcripts and their associated meetings/recordings. +Transcripts are the main entry point - any associated data is also removed. +""" + +from datetime import datetime, timedelta, timezone +from typing import TypedDict + +import structlog +from celery import shared_task +from databases import Database +from pydantic.types import PositiveInt + +from reflector.asynctask import asynctask +from reflector.db import get_database +from reflector.db.meetings import meetings +from reflector.db.recordings import recordings +from reflector.db.transcripts import transcripts, transcripts_controller +from reflector.settings import settings +from reflector.storage import get_transcripts_storage + +logger = structlog.get_logger(__name__) + + +class CleanupStats(TypedDict): + """Statistics for cleanup operation.""" + + transcripts_deleted: int + meetings_deleted: int + recordings_deleted: int + errors: list[str] + + +async def delete_single_transcript( + db: Database, transcript_data: dict, stats: CleanupStats +): + transcript_id = transcript_data["id"] + meeting_id = transcript_data["meeting_id"] + recording_id = transcript_data["recording_id"] + + try: + async with db.transaction(isolation="serializable"): + if meeting_id: + await db.execute(meetings.delete().where(meetings.c.id == meeting_id)) + stats["meetings_deleted"] += 1 + logger.info("Deleted associated meeting", meeting_id=meeting_id) + + if recording_id: + recording = await db.fetch_one( + recordings.select().where(recordings.c.id == recording_id) + ) + if recording: + try: + await get_transcripts_storage().delete_file( + recording["object_key"], bucket=recording["bucket_name"] + ) + except Exception as storage_error: + logger.warning( + "Failed to delete recording from storage", + recording_id=recording_id, + object_key=recording["object_key"], + error=str(storage_error), + ) + + await db.execute( + recordings.delete().where(recordings.c.id == recording_id) + ) + stats["recordings_deleted"] += 1 + logger.info( + "Deleted associated recording", recording_id=recording_id + ) + + await transcripts_controller.remove_by_id(transcript_id) + stats["transcripts_deleted"] += 1 + logger.info( + "Deleted transcript", + transcript_id=transcript_id, + created_at=transcript_data["created_at"].isoformat(), + ) + except Exception as e: + error_msg = f"Failed to delete transcript {transcript_id}: {str(e)}" + logger.error(error_msg, exc_info=e) + stats["errors"].append(error_msg) + + +async def cleanup_old_transcripts( + db: Database, cutoff_date: datetime, stats: CleanupStats +): + """Delete old anonymous transcripts and their associated recordings/meetings.""" + query = transcripts.select().where( + (transcripts.c.created_at < cutoff_date) & (transcripts.c.user_id.is_(None)) + ) + old_transcripts = await db.fetch_all(query) + + logger.info(f"Found {len(old_transcripts)} old transcripts to delete") + + for transcript_data in old_transcripts: + await delete_single_transcript(db, transcript_data, stats) + + +def log_cleanup_results(stats: CleanupStats): + logger.info( + "Cleanup completed", + transcripts_deleted=stats["transcripts_deleted"], + meetings_deleted=stats["meetings_deleted"], + recordings_deleted=stats["recordings_deleted"], + errors_count=len(stats["errors"]), + ) + + if stats["errors"]: + logger.warning( + "Cleanup completed with errors", + errors=stats["errors"][:10], + ) + + +async def cleanup_old_public_data( + days: PositiveInt | None = None, +) -> CleanupStats | None: + if days is None: + days = settings.PUBLIC_DATA_RETENTION_DAYS + + if not settings.PUBLIC_MODE: + logger.info("Skipping cleanup - not a public instance") + return None + + cutoff_date = datetime.now(timezone.utc) - timedelta(days=days) + logger.info( + "Starting cleanup of old public data", + cutoff_date=cutoff_date.isoformat(), + ) + + stats: CleanupStats = { + "transcripts_deleted": 0, + "meetings_deleted": 0, + "recordings_deleted": 0, + "errors": [], + } + + db = get_database() + await cleanup_old_transcripts(db, cutoff_date, stats) + + log_cleanup_results(stats) + return stats + + +@shared_task( + autoretry_for=(Exception,), + retry_kwargs={"max_retries": 3, "countdown": 300}, +) +@asynctask +async def cleanup_old_public_data_task(days: int | None = None): + await cleanup_old_public_data(days=days) diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py new file mode 100644 index 00000000..6881dfa2 --- /dev/null +++ b/server/reflector/worker/ics_sync.py @@ -0,0 +1,175 @@ +from datetime import datetime, timedelta, timezone + +import structlog +from celery import shared_task +from celery.utils.log import get_task_logger + +from reflector.asynctask import asynctask +from reflector.db.calendar_events import calendar_events_controller +from reflector.db.meetings import meetings_controller +from reflector.db.rooms import Room, rooms_controller +from reflector.redis_cache import RedisAsyncLock +from reflector.services.ics_sync import SyncStatus, ics_sync_service +from reflector.video_platforms.factory import create_platform_client, get_platform + +logger = structlog.wrap_logger(get_task_logger(__name__)) + + +@shared_task +@asynctask +async def sync_room_ics(room_id: str): + try: + room = await rooms_controller.get_by_id(room_id) + if not room: + logger.warning("Room not found for ICS sync", room_id=room_id) + return + + if not room.ics_enabled or not room.ics_url: + logger.debug("ICS not enabled for room", room_id=room_id) + return + + logger.info("Starting ICS sync for room", room_id=room_id, room_name=room.name) + result = await ics_sync_service.sync_room_calendar(room) + + if result["status"] == SyncStatus.SUCCESS: + logger.info( + "ICS sync completed successfully", + room_id=room_id, + events_found=result.get("events_found", 0), + events_created=result.get("events_created", 0), + events_updated=result.get("events_updated", 0), + events_deleted=result.get("events_deleted", 0), + ) + elif result["status"] == SyncStatus.UNCHANGED: + logger.debug("ICS content unchanged", room_id=room_id) + elif result["status"] == SyncStatus.ERROR: + logger.error("ICS sync failed", room_id=room_id, error=result.get("error")) + else: + logger.debug( + "ICS sync skipped", room_id=room_id, reason=result.get("reason") + ) + + except Exception as e: + logger.error("Unexpected error during ICS sync", room_id=room_id, error=str(e)) + + +@shared_task +@asynctask +async def sync_all_ics_calendars(): + try: + logger.info("Starting sync for all ICS-enabled rooms") + + ics_enabled_rooms = await rooms_controller.get_ics_enabled() + logger.info(f"Found {len(ics_enabled_rooms)} rooms with ICS enabled") + + for room in ics_enabled_rooms: + if not _should_sync(room): + logger.debug("Skipping room, not time to sync yet", room_id=room.id) + continue + + sync_room_ics.delay(room.id) + + logger.info("Queued sync tasks for all eligible rooms") + + except Exception as e: + logger.error("Error in sync_all_ics_calendars", error=str(e)) + + +def _should_sync(room) -> bool: + if not room.ics_last_sync: + return True + + time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync + return time_since_sync.total_seconds() >= room.ics_fetch_interval + + +MEETING_DEFAULT_DURATION = timedelta(hours=1) + + +async def create_upcoming_meetings_for_event(event, create_window, room: Room): + if event.start_time <= create_window: + return + existing_meeting = await meetings_controller.get_by_calendar_event(event.id, room) + + if existing_meeting: + return + + logger.info( + "Pre-creating meeting for calendar event", + room_id=room.id, + event_id=event.id, + event_title=event.title, + ) + + try: + end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION) + + client = create_platform_client(get_platform(room.platform)) + + meeting_data = await client.create_meeting( + room.name, + end_date=end_date, + room=room, + ) + await client.upload_logo(meeting_data.room_name, "./images/logo.png") + + meeting = await meetings_controller.create( + id=meeting_data.meeting_id, + room_name=meeting_data.room_name, + room_url=meeting_data.room_url, + host_room_url=meeting_data.host_room_url, + start_date=event.start_time, + end_date=end_date, + room=room, + calendar_event_id=event.id, + calendar_metadata={ + "title": event.title, + "description": event.description, + "attendees": event.attendees, + }, + ) + + logger.info( + "Meeting pre-created successfully", + meeting_id=meeting.id, + event_id=event.id, + ) + + except Exception as e: + logger.error( + "Failed to pre-create meeting", + room_id=room.id, + event_id=event.id, + error=str(e), + ) + + +@shared_task +@asynctask +async def create_upcoming_meetings(): + async with RedisAsyncLock("create_upcoming_meetings", skip_if_locked=True) as lock: + if not lock.acquired: + logger.warning( + "Another worker is already creating upcoming meetings, skipping" + ) + return + + try: + logger.info("Starting creation of upcoming meetings") + + ics_enabled_rooms = await rooms_controller.get_ics_enabled() + now = datetime.now(timezone.utc) + create_window = now - timedelta(minutes=6) + + for room in ics_enabled_rooms: + events = await calendar_events_controller.get_upcoming( + room.id, + minutes_ahead=7, + ) + + for event in events: + await create_upcoming_meetings_for_event(event, create_window, room) + logger.info("Completed pre-creation check for upcoming meetings") + + except Exception as e: + logger.error("Error in create_upcoming_meetings", error=str(e)) diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index 00126514..dd9c1059 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -1,5 +1,6 @@ import json import os +import re from datetime import datetime, timezone from urllib.parse import unquote @@ -9,27 +10,37 @@ import structlog from celery import shared_task from celery.utils.log import get_task_logger from pydantic import ValidationError +from redis.exceptions import LockError from reflector.db.meetings import meetings_controller from reflector.db.recordings import Recording, recordings_controller from reflector.db.rooms import rooms_controller -from reflector.db.transcripts import SourceKind, transcripts_controller +from reflector.db.transcripts import ( + SourceKind, + TranscriptParticipant, + transcripts_controller, +) from reflector.pipelines.main_file_pipeline import task_pipeline_file_process from reflector.pipelines.main_live_pipeline import asynctask +from reflector.pipelines.main_multitrack_pipeline import ( + task_pipeline_multitrack_process, +) +from reflector.pipelines.topic_processing import EmptyPipeline +from reflector.processors import AudioFileWriterProcessor +from reflector.processors.audio_waveform_processor import AudioWaveformProcessor +from reflector.redis_cache import get_redis_client from reflector.settings import settings -from reflector.whereby import get_room_sessions +from reflector.storage import get_transcripts_storage +from reflector.utils.daily import DailyRoomName, extract_base_room_name +from reflector.video_platforms.factory import create_platform_client +from reflector.video_platforms.whereby_utils import ( + parse_whereby_recording_filename, + room_name_to_whereby_api_room_name, +) logger = structlog.wrap_logger(get_task_logger(__name__)) -def parse_datetime_with_timezone(iso_string: str) -> datetime: - """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive).""" - dt = datetime.fromisoformat(iso_string) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt - - @shared_task def process_messages(): queue_url = settings.AWS_PROCESS_RECORDING_QUEUE_URL @@ -71,14 +82,16 @@ def process_messages(): logger.error("process_messages", error=str(e)) +# only whereby supported. @shared_task @asynctask async def process_recording(bucket_name: str, object_key: str): logger.info("Processing recording: %s/%s", bucket_name, object_key) - # extract a guid and a datetime from the object key - room_name = f"/{object_key[:36]}" - recorded_at = parse_datetime_with_timezone(object_key[37:57]) + room_name_part, recorded_at = parse_whereby_recording_filename(object_key) + + # we store whereby api room names, NOT whereby room names + room_name = room_name_to_whereby_api_room_name(room_name_part) meeting = await meetings_controller.get_by_room_name(room_name) room = await rooms_controller.get_by_id(meeting.room_id) @@ -100,6 +113,7 @@ async def process_recording(bucket_name: str, object_key: str): transcript, { "topics": [], + "participants": [], }, ) else: @@ -119,15 +133,15 @@ async def process_recording(bucket_name: str, object_key: str): upload_filename = transcript.data_path / f"upload{extension}" upload_filename.parent.mkdir(parents=True, exist_ok=True) - s3 = boto3.client( - "s3", - region_name=settings.TRANSCRIPT_STORAGE_AWS_REGION, - aws_access_key_id=settings.TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY, - ) + storage = get_transcripts_storage() - with open(upload_filename, "wb") as f: - s3.download_fileobj(bucket_name, object_key, f) + try: + with open(upload_filename, "wb") as f: + await storage.stream_to_fileobj(object_key, f, bucket=bucket_name) + except Exception: + # Clean up partial file on stream failure + upload_filename.unlink(missing_ok=True) + raise container = av.open(upload_filename.as_posix()) try: @@ -146,83 +160,364 @@ async def process_recording(bucket_name: str, object_key: str): @shared_task @asynctask -async def process_meetings(): - logger.info("Processing meetings") - meetings = await meetings_controller.get_all_active() - for meeting in meetings: - is_active = False - end_date = meeting.end_date - if end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=timezone.utc) - if end_date > datetime.now(timezone.utc): - response = await get_room_sessions(meeting.room_name) - room_sessions = response.get("results", []) - is_active = not room_sessions or any( - rs["endedAt"] is None for rs in room_sessions - ) - if not is_active: - await meetings_controller.update_meeting(meeting.id, is_active=False) - logger.info("Meeting %s is deactivated", meeting.id) +async def process_multitrack_recording( + bucket_name: str, + daily_room_name: DailyRoomName, + recording_id: str, + track_keys: list[str], +): + logger.info( + "Processing multitrack recording", + bucket=bucket_name, + room_name=daily_room_name, + recording_id=recording_id, + provided_keys=len(track_keys), + ) - logger.info("Processed meetings") + if not track_keys: + logger.warning("No audio track keys provided") + return + + tz = timezone.utc + recorded_at = datetime.now(tz) + try: + if track_keys: + folder = os.path.basename(os.path.dirname(track_keys[0])) + ts_match = re.search(r"(\d{14})$", folder) + if ts_match: + ts = ts_match.group(1) + recorded_at = datetime.strptime(ts, "%Y%m%d%H%M%S").replace(tzinfo=tz) + except Exception as e: + logger.warning( + f"Could not parse recorded_at from keys, using now() {recorded_at}", + e, + exc_info=True, + ) + + meeting = await meetings_controller.get_by_room_name(daily_room_name) + + room_name_base = extract_base_room_name(daily_room_name) + + room = await rooms_controller.get_by_name(room_name_base) + if not room: + raise Exception(f"Room not found: {room_name_base}") + + if not meeting: + raise Exception(f"Meeting not found: {room_name_base}") + + logger.info( + "Found existing Meeting for recording", + meeting_id=meeting.id, + room_name=daily_room_name, + recording_id=recording_id, + ) + + recording = await recordings_controller.get_by_id(recording_id) + if not recording: + object_key_dir = os.path.dirname(track_keys[0]) if track_keys else "" + recording = await recordings_controller.create( + Recording( + id=recording_id, + bucket_name=bucket_name, + object_key=object_key_dir, + recorded_at=recorded_at, + meeting_id=meeting.id, + track_keys=track_keys, + ) + ) + else: + # Recording already exists; assume metadata was set at creation time + pass + + transcript = await transcripts_controller.get_by_recording_id(recording.id) + if transcript: + await transcripts_controller.update( + transcript, + { + "topics": [], + "participants": [], + }, + ) + else: + transcript = await transcripts_controller.add( + "", + source_kind=SourceKind.ROOM, + source_language="en", + target_language="en", + user_id=room.user_id, + recording_id=recording.id, + share_mode="public", + meeting_id=meeting.id, + room_id=room.id, + ) + + try: + daily_client = create_platform_client("daily") + + id_to_name = {} + id_to_user_id = {} + + mtg_session_id = None + try: + rec_details = await daily_client.get_recording(recording_id) + mtg_session_id = rec_details.get("mtgSessionId") + except Exception as e: + logger.warning( + "Failed to fetch Daily recording details", + error=str(e), + recording_id=recording_id, + exc_info=True, + ) + + if mtg_session_id: + try: + payload = await daily_client.get_meeting_participants(mtg_session_id) + for p in payload.get("data", []): + pid = p.get("participant_id") + name = p.get("user_name") + user_id = p.get("user_id") + if pid and name: + id_to_name[pid] = name + if pid and user_id: + id_to_user_id[pid] = user_id + except Exception as e: + logger.warning( + "Failed to fetch Daily meeting participants", + error=str(e), + mtg_session_id=mtg_session_id, + exc_info=True, + ) + else: + logger.warning( + "No mtgSessionId found for recording; participant names may be generic", + recording_id=recording_id, + ) + + for idx, key in enumerate(track_keys): + base = os.path.basename(key) + m = re.search(r"\d{13,}-([0-9a-fA-F-]{36})-cam-audio-", base) + participant_id = m.group(1) if m else None + + default_name = f"Speaker {idx}" + name = id_to_name.get(participant_id, default_name) + user_id = id_to_user_id.get(participant_id) + + participant = TranscriptParticipant( + id=participant_id, speaker=idx, name=name, user_id=user_id + ) + await transcripts_controller.upsert_participant(transcript, participant) + + except Exception as e: + logger.warning("Failed to map participant names", error=str(e), exc_info=True) + + task_pipeline_multitrack_process.delay( + transcript_id=transcript.id, + bucket_name=bucket_name, + track_keys=track_keys, + ) + + +@shared_task +@asynctask +async def process_meetings(): + """ + Checks which meetings are still active and deactivates those that have ended. + + Deactivation logic: + - Active sessions: Keep meeting active regardless of scheduled time + - No active sessions: + * Calendar meetings: + - If previously used (had sessions): Deactivate immediately + - If never used: Keep active until scheduled end time, then deactivate + * On-the-fly meetings: Deactivate immediately (created when someone joins, + so no sessions means everyone left) + + Uses distributed locking to prevent race conditions when multiple workers + process the same meeting simultaneously. + """ + meetings = await meetings_controller.get_all_active() + logger.info(f"Processing {len(meetings)} meetings") + current_time = datetime.now(timezone.utc) + redis_client = get_redis_client() + processed_count = 0 + skipped_count = 0 + for meeting in meetings: + logger_ = logger.bind(meeting_id=meeting.id, room_name=meeting.room_name) + logger_.info("Processing meeting") + lock_key = f"meeting_process_lock:{meeting.id}" + lock = redis_client.lock(lock_key, timeout=120) + + try: + if not lock.acquire(blocking=False): + logger_.debug("Meeting is being processed by another worker, skipping") + skipped_count += 1 + continue + + # Process the meeting + should_deactivate = False + end_date = meeting.end_date + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + + client = create_platform_client(meeting.platform) + room_sessions = await client.get_room_sessions(meeting.room_name) + + try: + # Extend lock after operation to ensure we still hold it + lock.extend(120, replace_ttl=True) + except LockError: + logger_.warning("Lost lock for meeting, skipping") + continue + + has_active_sessions = room_sessions and any( + s.ended_at is None for s in room_sessions + ) + has_had_sessions = bool(room_sessions) + logger_.info( + f"found {has_active_sessions} active sessions, had {has_had_sessions}" + ) + + if has_active_sessions: + logger_.debug("Meeting still has active sessions, keep it") + elif has_had_sessions: + should_deactivate = True + logger_.info("Meeting ended - all participants left") + elif current_time > end_date: + should_deactivate = True + logger_.info( + "Meeting deactivated - scheduled time ended with no participants", + ) + else: + logger_.debug("Meeting not yet started, keep it") + + if should_deactivate: + await meetings_controller.update_meeting(meeting.id, is_active=False) + logger_.info("Meeting is deactivated") + + processed_count += 1 + + except Exception: + logger_.error("Error processing meeting", exc_info=True) + finally: + try: + lock.release() + except LockError: + pass # Lock already released or expired + + logger.debug( + "Processed meetings finished", + processed_count=processed_count, + skipped_count=skipped_count, + ) + + +async def convert_audio_and_waveform(transcript) -> None: + """Convert WebM to MP3 and generate waveform for Daily.co recordings. + + This bypasses the full file pipeline which would overwrite stub data. + """ + try: + logger.info( + "Converting audio to MP3 and generating waveform", + transcript_id=transcript.id, + ) + + upload_path = transcript.data_path / "upload.webm" + mp3_path = transcript.audio_mp3_filename + + # Convert WebM to MP3 + mp3_writer = AudioFileWriterProcessor(path=mp3_path) + + container = av.open(str(upload_path)) + for frame in container.decode(audio=0): + await mp3_writer.push(frame) + await mp3_writer.flush() + container.close() + + logger.info( + "Converted WebM to MP3", + transcript_id=transcript.id, + mp3_size=mp3_path.stat().st_size, + ) + + waveform_processor = AudioWaveformProcessor( + audio_path=mp3_path, + waveform_path=transcript.audio_waveform_filename, + ) + waveform_processor.set_pipeline(EmptyPipeline(logger)) + await waveform_processor.flush() + + logger.info( + "Generated waveform", + transcript_id=transcript.id, + waveform_path=transcript.audio_waveform_filename, + ) + + # Update transcript status to ended (successful) + await transcripts_controller.update(transcript, {"status": "ended"}) + + except Exception as e: + logger.error( + "Failed to convert audio or generate waveform", + transcript_id=transcript.id, + error=str(e), + ) + # Keep status as uploaded even if conversion fails + pass @shared_task @asynctask async def reprocess_failed_recordings(): """ - Find recordings in the S3 bucket and check if they have proper transcriptions. + Find recordings in Whereby S3 bucket and check if they have proper transcriptions. If not, requeue them for processing. - """ - logger.info("Checking for recordings that need processing or reprocessing") - s3 = boto3.client( - "s3", - region_name=settings.TRANSCRIPT_STORAGE_AWS_REGION, - aws_access_key_id=settings.TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY, - ) + Note: Daily.co recordings are processed via webhooks, not this cron job. + """ + logger.info("Checking Whereby recordings that need processing or reprocessing") + + if not settings.WHEREBY_STORAGE_AWS_BUCKET_NAME: + raise ValueError( + "WHEREBY_STORAGE_AWS_BUCKET_NAME required for Whereby recording reprocessing. " + "Set WHEREBY_STORAGE_AWS_BUCKET_NAME environment variable." + ) + + storage = get_transcripts_storage() + bucket_name = settings.WHEREBY_STORAGE_AWS_BUCKET_NAME reprocessed_count = 0 try: - paginator = s3.get_paginator("list_objects_v2") - bucket_name = settings.RECORDING_STORAGE_AWS_BUCKET_NAME - pages = paginator.paginate(Bucket=bucket_name) + object_keys = await storage.list_objects(prefix="", bucket=bucket_name) - for page in pages: - if "Contents" not in page: + for object_key in object_keys: + if not object_key.endswith(".mp4"): continue - for obj in page["Contents"]: - object_key = obj["Key"] + recording = await recordings_controller.get_by_object_key( + bucket_name, object_key + ) + if not recording: + logger.info(f"Queueing recording for processing: {object_key}") + process_recording.delay(bucket_name, object_key) + reprocessed_count += 1 + continue - if not (object_key.endswith(".mp4")): - continue - - recording = await recordings_controller.get_by_object_key( - bucket_name, object_key + transcript = None + try: + transcript = await transcripts_controller.get_by_recording_id( + recording.id + ) + except ValidationError: + await transcripts_controller.remove_by_recording_id(recording.id) + logger.warning( + f"Removed invalid transcript for recording: {recording.id}" ) - if not recording: - logger.info(f"Queueing recording for processing: {object_key}") - process_recording.delay(bucket_name, object_key) - reprocessed_count += 1 - continue - transcript = None - try: - transcript = await transcripts_controller.get_by_recording_id( - recording.id - ) - except ValidationError: - await transcripts_controller.remove_by_recording_id(recording.id) - logger.warning( - f"Removed invalid transcript for recording: {recording.id}" - ) - - if transcript is None or transcript.status == "error": - logger.info(f"Queueing recording for processing: {object_key}") - process_recording.delay(bucket_name, object_key) - reprocessed_count += 1 + if transcript is None or transcript.status == "error": + logger.info(f"Queueing recording for processing: {object_key}") + process_recording.delay(bucket_name, object_key) + reprocessed_count += 1 except Exception as e: logger.error(f"Error checking S3 bucket: {str(e)}") diff --git a/server/reflector/worker/webhook.py b/server/reflector/worker/webhook.py new file mode 100644 index 00000000..57b294d8 --- /dev/null +++ b/server/reflector/worker/webhook.py @@ -0,0 +1,299 @@ +"""Webhook task for sending transcript notifications.""" + +import hashlib +import hmac +import json +import uuid +from datetime import datetime, timezone + +import httpx +import structlog +from celery import shared_task +from celery.utils.log import get_task_logger + +from reflector.db.calendar_events import calendar_events_controller +from reflector.db.meetings import meetings_controller +from reflector.db.rooms import rooms_controller +from reflector.db.transcripts import transcripts_controller +from reflector.pipelines.main_live_pipeline import asynctask +from reflector.settings import settings +from reflector.utils.webvtt import topics_to_webvtt + +logger = structlog.wrap_logger(get_task_logger(__name__)) + + +def generate_webhook_signature(payload: bytes, secret: str, timestamp: str) -> str: + """Generate HMAC signature for webhook payload.""" + signed_payload = f"{timestamp}.{payload.decode('utf-8')}" + hmac_obj = hmac.new( + secret.encode("utf-8"), + signed_payload.encode("utf-8"), + hashlib.sha256, + ) + return hmac_obj.hexdigest() + + +@shared_task( + bind=True, + max_retries=30, + default_retry_delay=60, + retry_backoff=True, + retry_backoff_max=3600, # Max 1 hour between retries +) +@asynctask +async def send_transcript_webhook( + self, + transcript_id: str, + room_id: str, + event_id: str, +): + log = logger.bind( + transcript_id=transcript_id, + room_id=room_id, + retry_count=self.request.retries, + ) + + try: + # Fetch transcript and room + transcript = await transcripts_controller.get_by_id(transcript_id) + if not transcript: + log.error("Transcript not found, skipping webhook") + return + + room = await rooms_controller.get_by_id(room_id) + if not room: + log.error("Room not found, skipping webhook") + return + + if not room.webhook_url: + log.info("No webhook URL configured for room, skipping") + return + + # Generate WebVTT content from topics + topics_data = [] + + if transcript.topics: + # Build topics data with diarized content per topic + for topic in transcript.topics: + topic_webvtt = topics_to_webvtt([topic]) if topic.words else "" + topics_data.append( + { + "title": topic.title, + "summary": topic.summary, + "timestamp": topic.timestamp, + "duration": topic.duration, + "webvtt": topic_webvtt, + } + ) + + # Fetch meeting and calendar event if they exist + calendar_event = None + try: + if transcript.meeting_id: + meeting = await meetings_controller.get_by_id(transcript.meeting_id) + if meeting and meeting.calendar_event_id: + calendar_event = await calendar_events_controller.get_by_id( + meeting.calendar_event_id + ) + except Exception as e: + logger.error("Error fetching meeting or calendar event", error=str(e)) + + # Build webhook payload + frontend_url = f"{settings.UI_BASE_URL}/transcripts/{transcript.id}" + participants = [ + {"id": p.id, "name": p.name, "speaker": p.speaker} + for p in (transcript.participants or []) + ] + payload_data = { + "event": "transcript.completed", + "event_id": event_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "transcript": { + "id": transcript.id, + "room_id": transcript.room_id, + "created_at": transcript.created_at.isoformat(), + "duration": transcript.duration, + "title": transcript.title, + "short_summary": transcript.short_summary, + "long_summary": transcript.long_summary, + "webvtt": transcript.webvtt, + "topics": topics_data, + "participants": participants, + "source_language": transcript.source_language, + "target_language": transcript.target_language, + "status": transcript.status, + "frontend_url": frontend_url, + }, + "room": { + "id": room.id, + "name": room.name, + }, + } + + # Always include calendar_event field, even if no event is present + payload_data["calendar_event"] = {} + + # Add calendar event data if present + if calendar_event: + calendar_data = { + "id": calendar_event.id, + "ics_uid": calendar_event.ics_uid, + "title": calendar_event.title, + "start_time": calendar_event.start_time.isoformat() + if calendar_event.start_time + else None, + "end_time": calendar_event.end_time.isoformat() + if calendar_event.end_time + else None, + } + + # Add optional fields only if they exist + if calendar_event.description: + calendar_data["description"] = calendar_event.description + if calendar_event.location: + calendar_data["location"] = calendar_event.location + if calendar_event.attendees: + calendar_data["attendees"] = calendar_event.attendees + + payload_data["calendar_event"] = calendar_data + + # Convert to JSON + payload_json = json.dumps(payload_data, separators=(",", ":")) + payload_bytes = payload_json.encode("utf-8") + + # Generate signature if secret is configured + headers = { + "Content-Type": "application/json", + "User-Agent": "Reflector-Webhook/1.0", + "X-Webhook-Event": "transcript.completed", + "X-Webhook-Retry": str(self.request.retries), + } + + if room.webhook_secret: + timestamp = str(int(datetime.now(timezone.utc).timestamp())) + signature = generate_webhook_signature( + payload_bytes, room.webhook_secret, timestamp + ) + headers["X-Webhook-Signature"] = f"t={timestamp},v1={signature}" + + # Send webhook with timeout + async with httpx.AsyncClient(timeout=30.0) as client: + log.info( + "Sending webhook", + url=room.webhook_url, + payload_size=len(payload_bytes), + ) + + response = await client.post( + room.webhook_url, + content=payload_bytes, + headers=headers, + ) + + response.raise_for_status() + + log.info( + "Webhook sent successfully", + status_code=response.status_code, + response_size=len(response.content), + ) + + except httpx.HTTPStatusError as e: + log.error( + "Webhook failed with HTTP error", + status_code=e.response.status_code, + response_text=e.response.text[:500], # First 500 chars + ) + + # Don't retry on client errors (4xx) + if 400 <= e.response.status_code < 500: + log.error("Client error, not retrying") + return + + # Retry on server errors (5xx) + raise self.retry(exc=e) + + except (httpx.ConnectError, httpx.TimeoutException) as e: + # Retry on network errors + log.error("Webhook failed with connection error", error=str(e)) + raise self.retry(exc=e) + + except Exception as e: + # Retry on unexpected errors + log.exception("Unexpected error in webhook task", error=str(e)) + raise self.retry(exc=e) + + +async def test_webhook(room_id: str) -> dict: + """ + Test webhook configuration by sending a sample payload. + Returns immediately with success/failure status. + This is the shared implementation used by both the API endpoint and Celery task. + """ + try: + room = await rooms_controller.get_by_id(room_id) + if not room: + return {"success": False, "error": "Room not found"} + + if not room.webhook_url: + return {"success": False, "error": "No webhook URL configured"} + + now = (datetime.now(timezone.utc).isoformat(),) + payload_data = { + "event": "test", + "event_id": uuid.uuid4().hex, + "timestamp": now, + "message": "This is a test webhook from Reflector", + "room": { + "id": room.id, + "name": room.name, + }, + } + + payload_json = json.dumps(payload_data, separators=(",", ":")) + payload_bytes = payload_json.encode("utf-8") + + # Generate headers with signature + headers = { + "Content-Type": "application/json", + "User-Agent": "Reflector-Webhook/1.0", + "X-Webhook-Event": "test", + } + + if room.webhook_secret: + timestamp = str(int(datetime.now(timezone.utc).timestamp())) + signature = generate_webhook_signature( + payload_bytes, room.webhook_secret, timestamp + ) + headers["X-Webhook-Signature"] = f"t={timestamp},v1={signature}" + + # Send test webhook with short timeout + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + room.webhook_url, + content=payload_bytes, + headers=headers, + ) + + return { + "success": response.is_success, + "status_code": response.status_code, + "message": f"Webhook test {'successful' if response.is_success else 'failed'}", + "response_preview": response.text if response.text else None, + } + + except httpx.TimeoutException: + return { + "success": False, + "error": "Webhook request timed out (10 seconds)", + } + except httpx.ConnectError as e: + return { + "success": False, + "error": f"Could not connect to webhook URL: {str(e)}", + } + except Exception as e: + return { + "success": False, + "error": f"Unexpected error: {str(e)}", + } diff --git a/server/reflector/ws_manager.py b/server/reflector/ws_manager.py index 07790e09..a1f620c4 100644 --- a/server/reflector/ws_manager.py +++ b/server/reflector/ws_manager.py @@ -65,8 +65,13 @@ class WebsocketManager: self.tasks: dict = {} self.pubsub_client = pubsub_client - async def add_user_to_room(self, room_id: str, websocket: WebSocket) -> None: - await websocket.accept() + async def add_user_to_room( + self, room_id: str, websocket: WebSocket, subprotocol: str | None = None + ) -> None: + if subprotocol: + await websocket.accept(subprotocol=subprotocol) + else: + await websocket.accept() if room_id in self.rooms: self.rooms[room_id].append(websocket) diff --git a/server/runserver.sh b/server/runserver.sh index a4fb6869..9cccaacb 100755 --- a/server/runserver.sh +++ b/server/runserver.sh @@ -2,7 +2,7 @@ if [ "${ENTRYPOINT}" = "server" ]; then uv run alembic upgrade head - uv run -m reflector.app + uv run uvicorn reflector.app:app --host 0.0.0.0 --port 1250 elif [ "${ENTRYPOINT}" = "worker" ]; then uv run celery -A reflector.worker.app worker --loglevel=info elif [ "${ENTRYPOINT}" = "beat" ]; then diff --git a/server/scripts/list_daily_webhooks.py b/server/scripts/list_daily_webhooks.py new file mode 100755 index 00000000..e2e3c912 --- /dev/null +++ b/server/scripts/list_daily_webhooks.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from reflector.dailyco_api import DailyApiClient +from reflector.settings import settings + + +async def list_webhooks(): + """List all Daily.co webhooks for this account using dailyco_api module.""" + if not settings.DAILY_API_KEY: + print("Error: DAILY_API_KEY not set") + return 1 + + async with DailyApiClient(api_key=settings.DAILY_API_KEY) as client: + try: + webhooks = await client.list_webhooks() + + if not webhooks: + print("No webhooks found") + return 0 + + print(f"Found {len(webhooks)} webhook(s):\n") + + for webhook in webhooks: + print("=" * 80) + print(f"UUID: {webhook.uuid}") + print(f"URL: {webhook.url}") + print(f"State: {webhook.state}") + print(f"Event Types: {', '.join(webhook.eventTypes)}") + print( + f"HMAC Secret: {'✓ Configured' if webhook.hmac else '✗ Not set'}" + ) + print() + + print("=" * 80) + print( + f"\nCurrent DAILY_WEBHOOK_UUID in settings: {settings.DAILY_WEBHOOK_UUID or '(not set)'}" + ) + + return 0 + + except Exception as e: + print(f"Error fetching webhooks: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(list_webhooks())) diff --git a/server/scripts/recreate_daily_webhook.py b/server/scripts/recreate_daily_webhook.py new file mode 100644 index 00000000..e4ac9ce9 --- /dev/null +++ b/server/scripts/recreate_daily_webhook.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from reflector.dailyco_api import ( + CreateWebhookRequest, + DailyApiClient, +) +from reflector.settings import settings + + +async def setup_webhook(webhook_url: str): + """ + Create or update Daily.co webhook for this environment using dailyco_api module. + Uses DAILY_WEBHOOK_UUID to identify existing webhook. + """ + if not settings.DAILY_API_KEY: + print("Error: DAILY_API_KEY not set") + return 1 + + if not settings.DAILY_WEBHOOK_SECRET: + print("Error: DAILY_WEBHOOK_SECRET not set") + return 1 + + event_types = [ + "participant.joined", + "participant.left", + "recording.started", + "recording.ready-to-download", + "recording.error", + ] + + async with DailyApiClient(api_key=settings.DAILY_API_KEY) as client: + webhook_uuid = settings.DAILY_WEBHOOK_UUID + + if webhook_uuid: + print(f"Updating existing webhook {webhook_uuid}...") + try: + # Note: Daily.co doesn't support PATCH well, so we delete + recreate + await client.delete_webhook(webhook_uuid) + print(f"Deleted old webhook {webhook_uuid}") + + request = CreateWebhookRequest( + url=webhook_url, + eventTypes=event_types, + hmac=settings.DAILY_WEBHOOK_SECRET, + ) + result = await client.create_webhook(request) + + print( + f"✓ Created replacement webhook {result.uuid} (state: {result.state})" + ) + print(f" URL: {result.url}") + + webhook_uuid = result.uuid + + except Exception as e: + if hasattr(e, "response") and e.response.status_code == 404: + print(f"Webhook {webhook_uuid} not found, creating new one...") + webhook_uuid = None # Fall through to creation + else: + print(f"Error updating webhook: {e}") + return 1 + + if not webhook_uuid: + print("Creating new webhook...") + request = CreateWebhookRequest( + url=webhook_url, + eventTypes=event_types, + hmac=settings.DAILY_WEBHOOK_SECRET, + ) + result = await client.create_webhook(request) + webhook_uuid = result.uuid + + print(f"✓ Created webhook {webhook_uuid} (state: {result.state})") + print(f" URL: {result.url}") + print() + print("=" * 60) + print("IMPORTANT: Add this to your environment variables:") + print("=" * 60) + print(f"DAILY_WEBHOOK_UUID: {webhook_uuid}") + print("=" * 60) + print() + + # Try to write UUID to .env file + env_file = Path(__file__).parent.parent / ".env" + if env_file.exists(): + lines = env_file.read_text().splitlines() + updated = False + + # Update existing DAILY_WEBHOOK_UUID line or add it + for i, line in enumerate(lines): + if line.startswith("DAILY_WEBHOOK_UUID="): + lines[i] = f"DAILY_WEBHOOK_UUID={webhook_uuid}" + updated = True + break + + if not updated: + lines.append(f"DAILY_WEBHOOK_UUID={webhook_uuid}") + + env_file.write_text("\n".join(lines) + "\n") + print(f"✓ Also saved to local .env file") + else: + print(f"⚠ Local .env file not found - please add manually") + + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python recreate_daily_webhook.py ") + print( + "Example: python recreate_daily_webhook.py https://example.com/v1/daily/webhook" + ) + print() + print("Behavior:") + print(" - If DAILY_WEBHOOK_UUID set: Deletes old webhook, creates new one") + print( + " - If DAILY_WEBHOOK_UUID empty: Creates new webhook, saves UUID to .env" + ) + sys.exit(1) + + sys.exit(asyncio.run(setup_webhook(sys.argv[1]))) diff --git a/server/test.ics b/server/test.ics new file mode 100644 index 00000000..8d0b6653 --- /dev/null +++ b/server/test.ics @@ -0,0 +1,29 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +PRODID:-//Fastmail/2020.5/EN +X-APPLE-CALENDAR-COLOR:#0F6A0F +X-WR-CALNAME:Test reflector +X-WR-TIMEZONE:America/Costa_Rica +BEGIN:VTIMEZONE +TZID:America/Costa_Rica +BEGIN:STANDARD +DTSTART:19700101T000000 +TZOFFSETFROM:-0600 +TZOFFSETTO:-0600 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +ATTENDEE;CN=Mathieu Virbel;PARTSTAT=ACCEPTED:MAILTO:mathieu@monadical.com +DTEND;TZID=America/Costa_Rica:20250819T143000 +DTSTAMP:20250819T155951Z +DTSTART;TZID=America/Costa_Rica:20250819T140000 +LOCATION:http://localhost:1250/mathieu +ORGANIZER;CN=Mathieu Virbel:MAILTO:mathieu@monadical.com +SEQUENCE:1 +SUMMARY:Checkin +TRANSP:OPAQUE +UID:867df50d-8105-4c58-9280-2b5d26cc9cd3 +END:VEVENT +END:VCALENDAR diff --git a/server/tests/conftest.py b/server/tests/conftest.py index d739751d..7d6c4302 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -1,9 +1,22 @@ import os +from contextlib import asynccontextmanager from tempfile import NamedTemporaryFile from unittest.mock import patch import pytest +from reflector.schemas.platform import WHEREBY_PLATFORM + + +@pytest.fixture(scope="session", autouse=True) +def register_mock_platform(): + from mocks.mock_platform import MockPlatformClient + + from reflector.video_platforms.registry import register_platform + + register_platform(WHEREBY_PLATFORM, MockPlatformClient) + yield + @pytest.fixture(scope="session", autouse=True) def settings_configuration(): @@ -178,6 +191,63 @@ async def dummy_diarization(): yield +@pytest.fixture +async def dummy_file_transcript(): + from reflector.processors.file_transcript import FileTranscriptProcessor + from reflector.processors.types import Transcript, Word + + class TestFileTranscriptProcessor(FileTranscriptProcessor): + async def _transcript(self, data): + return Transcript( + text="Hello world. How are you today?", + words=[ + Word(start=0.0, end=0.5, text="Hello", speaker=0), + Word(start=0.5, end=0.6, text=" ", speaker=0), + Word(start=0.6, end=1.0, text="world", speaker=0), + Word(start=1.0, end=1.1, text=".", speaker=0), + Word(start=1.1, end=1.2, text=" ", speaker=0), + Word(start=1.2, end=1.5, text="How", speaker=0), + Word(start=1.5, end=1.6, text=" ", speaker=0), + Word(start=1.6, end=1.8, text="are", speaker=0), + Word(start=1.8, end=1.9, text=" ", speaker=0), + Word(start=1.9, end=2.1, text="you", speaker=0), + Word(start=2.1, end=2.2, text=" ", speaker=0), + Word(start=2.2, end=2.5, text="today", speaker=0), + Word(start=2.5, end=2.6, text="?", speaker=0), + ], + ) + + with patch( + "reflector.processors.file_transcript_auto.FileTranscriptAutoProcessor.__new__" + ) as mock_auto: + mock_auto.return_value = TestFileTranscriptProcessor() + yield + + +@pytest.fixture +async def dummy_file_diarization(): + from reflector.processors.file_diarization import ( + FileDiarizationOutput, + FileDiarizationProcessor, + ) + from reflector.processors.types import DiarizationSegment + + class TestFileDiarizationProcessor(FileDiarizationProcessor): + async def _diarize(self, data): + return FileDiarizationOutput( + diarization=[ + DiarizationSegment(start=0.0, end=1.1, speaker=0), + DiarizationSegment(start=1.2, end=2.6, speaker=1), + ] + ) + + with patch( + "reflector.processors.file_diarization_auto.FileDiarizationAutoProcessor.__new__" + ) as mock_auto: + mock_auto.return_value = TestFileDiarizationProcessor() + yield + + @pytest.fixture async def dummy_transcript_translator(): from reflector.processors.transcript_translator import TranscriptTranslatorProcessor @@ -238,9 +308,13 @@ async def dummy_storage(): with ( patch("reflector.storage.base.Storage.get_instance") as mock_storage, patch("reflector.storage.get_transcripts_storage") as mock_get_transcripts, + patch( + "reflector.pipelines.main_file_pipeline.get_transcripts_storage" + ) as mock_get_transcripts2, ): mock_storage.return_value = dummy mock_get_transcripts.return_value = dummy + mock_get_transcripts2.return_value = dummy yield @@ -260,7 +334,10 @@ def celery_config(): @pytest.fixture(scope="session") def celery_includes(): - return ["reflector.pipelines.main_live_pipeline"] + return [ + "reflector.pipelines.main_live_pipeline", + "reflector.pipelines.main_file_pipeline", + ] @pytest.fixture @@ -273,6 +350,166 @@ async def client(): yield ac +@pytest.fixture(autouse=True) +async def ws_manager_in_memory(monkeypatch): + """Replace Redis-based WS manager with an in-memory implementation for tests.""" + import asyncio + import json + + from reflector.ws_manager import WebsocketManager + + class _InMemorySubscriber: + def __init__(self, queue: asyncio.Queue): + self.queue = queue + + async def get_message(self, ignore_subscribe_messages: bool = True): + try: + return await asyncio.wait_for(self.queue.get(), timeout=0.05) + except Exception: + return None + + class InMemoryPubSubManager: + def __init__(self): + self.queues: dict[str, asyncio.Queue] = {} + self.connected = False + + async def connect(self) -> None: + self.connected = True + + async def disconnect(self) -> None: + self.connected = False + + async def send_json(self, room_id: str, message: dict) -> None: + if room_id not in self.queues: + self.queues[room_id] = asyncio.Queue() + payload = json.dumps(message).encode("utf-8") + await self.queues[room_id].put( + {"channel": room_id.encode("utf-8"), "data": payload} + ) + + async def subscribe(self, room_id: str): + if room_id not in self.queues: + self.queues[room_id] = asyncio.Queue() + return _InMemorySubscriber(self.queues[room_id]) + + async def unsubscribe(self, room_id: str) -> None: + # keep queue for potential later resubscribe within same test + pass + + pubsub = InMemoryPubSubManager() + ws_manager = WebsocketManager(pubsub_client=pubsub) + + def _get_ws_manager(): + return ws_manager + + # Patch all places that imported get_ws_manager at import time + monkeypatch.setattr("reflector.ws_manager.get_ws_manager", _get_ws_manager) + monkeypatch.setattr( + "reflector.pipelines.main_live_pipeline.get_ws_manager", _get_ws_manager + ) + monkeypatch.setattr( + "reflector.views.transcripts_websocket.get_ws_manager", _get_ws_manager + ) + monkeypatch.setattr( + "reflector.views.user_websocket.get_ws_manager", _get_ws_manager + ) + monkeypatch.setattr("reflector.views.transcripts.get_ws_manager", _get_ws_manager) + + # Websocket auth: avoid OAuth2 on websocket dependencies; allow anonymous + import reflector.auth as auth + + # Ensure FastAPI uses our override for routes that captured the original callable + from reflector.app import app as fastapi_app + + try: + fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None + except Exception: + pass + + # Stub Redis cache used by profanity filter to avoid external Redis + from reflector import redis_cache as rc + + class _FakeRedis: + def __init__(self): + self._data = {} + + def get(self, key): + value = self._data.get(key) + if value is None: + return None + if isinstance(value, bytes): + return value + return str(value).encode("utf-8") + + def setex(self, key, duration, value): + # ignore duration for tests + if isinstance(value, bytes): + self._data[key] = value + else: + self._data[key] = str(value).encode("utf-8") + + fake_redises: dict[int, _FakeRedis] = {} + + def _get_redis_client(db=0): + if db not in fake_redises: + fake_redises[db] = _FakeRedis() + return fake_redises[db] + + monkeypatch.setattr(rc, "get_redis_client", _get_redis_client) + + yield + + +@pytest.fixture +@pytest.mark.asyncio +async def authenticated_client(): + async with authenticated_client_ctx(): + yield + + +@pytest.fixture +@pytest.mark.asyncio +async def authenticated_client2(): + async with authenticated_client2_ctx(): + yield + + +@asynccontextmanager +async def authenticated_client_ctx(): + from reflector.app import app + from reflector.auth import current_user, current_user_optional + + app.dependency_overrides[current_user] = lambda: { + "sub": "randomuserid", + "email": "test@mail.com", + } + app.dependency_overrides[current_user_optional] = lambda: { + "sub": "randomuserid", + "email": "test@mail.com", + } + yield + del app.dependency_overrides[current_user] + del app.dependency_overrides[current_user_optional] + + +@asynccontextmanager +async def authenticated_client2_ctx(): + from reflector.app import app + from reflector.auth import current_user, current_user_optional + + app.dependency_overrides[current_user] = lambda: { + "sub": "randomuserid2", + "email": "test@mail.com", + } + app.dependency_overrides[current_user_optional] = lambda: { + "sub": "randomuserid2", + "email": "test@mail.com", + } + yield + del app.dependency_overrides[current_user] + del app.dependency_overrides[current_user_optional] + + @pytest.fixture(scope="session") def fake_mp3_upload(): with patch( @@ -302,7 +539,7 @@ async def fake_transcript_with_topics(tmpdir, client): transcript = await transcripts_controller.get_by_id(tid) assert transcript is not None - await transcripts_controller.update(transcript, {"status": "finished"}) + await transcripts_controller.update(transcript, {"status": "ended"}) # manually copy a file at the expected location audio_filename = transcript.audio_mp3_filename diff --git a/server/tests/mocks/__init__.py b/server/tests/mocks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/tests/mocks/mock_platform.py b/server/tests/mocks/mock_platform.py new file mode 100644 index 00000000..b4d9ae90 --- /dev/null +++ b/server/tests/mocks/mock_platform.py @@ -0,0 +1,110 @@ +import uuid +from datetime import datetime +from typing import Any, Dict, Literal, Optional + +from reflector.db.rooms import Room +from reflector.utils.string import NonEmptyString +from reflector.video_platforms.base import ( + ROOM_PREFIX_SEPARATOR, + MeetingData, + SessionData, + VideoPlatformClient, + VideoPlatformConfig, +) + +MockPlatform = Literal["mock"] + + +class MockPlatformClient(VideoPlatformClient): + PLATFORM_NAME: MockPlatform = "mock" + + def __init__(self, config: VideoPlatformConfig): + super().__init__(config) + self._rooms: Dict[str, Dict[str, Any]] = {} + self._webhook_calls: list[Dict[str, Any]] = [] + + async def create_meeting( + self, room_name_prefix: str, end_date: datetime, room: Room + ) -> MeetingData: + meeting_id = str(uuid.uuid4()) + room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{meeting_id[:8]}" + room_url = f"https://mock.video/{room_name}" + host_room_url = f"{room_url}?host=true" + + self._rooms[room_name] = { + "id": meeting_id, + "name": room_name, + "url": room_url, + "host_url": host_room_url, + "end_date": end_date, + "room": room, + "participants": [], + "is_active": True, + } + + return MeetingData.model_construct( + meeting_id=meeting_id, + room_name=room_name, + room_url=room_url, + host_room_url=host_room_url, + platform="whereby", + extra_data={"mock": True}, + ) + + async def get_room_sessions(self, room_name: NonEmptyString) -> list[SessionData]: + if room_name not in self._rooms: + return [] + + room_data = self._rooms[room_name] + return [ + SessionData( + session_id=room_data["id"], + started_at=datetime.utcnow(), + ended_at=None if room_data["is_active"] else datetime.utcnow(), + ) + ] + + async def delete_room(self, room_name: str) -> bool: + if room_name in self._rooms: + self._rooms[room_name]["is_active"] = False + return True + return False + + async def upload_logo(self, room_name: str, logo_path: str) -> bool: + if room_name in self._rooms: + self._rooms[room_name]["logo_path"] = logo_path + return True + return False + + def verify_webhook_signature( + self, body: bytes, signature: str, timestamp: Optional[str] = None + ) -> bool: + return signature == "valid" + + def add_participant( + self, room_name: str, participant_id: str, participant_name: str + ): + if room_name in self._rooms: + self._rooms[room_name]["participants"].append( + { + "id": participant_id, + "name": participant_name, + "joined_at": datetime.utcnow().isoformat(), + } + ) + + def trigger_webhook(self, event_type: str, data: Dict[str, Any]): + self._webhook_calls.append( + { + "type": event_type, + "data": data, + "timestamp": datetime.utcnow().isoformat(), + } + ) + + def get_webhook_calls(self) -> list[Dict[str, Any]]: + return self._webhook_calls.copy() + + def clear_data(self): + self._rooms.clear() + self._webhook_calls.clear() diff --git a/server/tests/test_attendee_parsing_bug.ics b/server/tests/test_attendee_parsing_bug.ics new file mode 100644 index 00000000..1adc99fe --- /dev/null +++ b/server/tests/test_attendee_parsing_bug.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +PRODID:-//Test/1.0/EN +X-WR-CALNAME:Test Attendee Bug +BEGIN:VEVENT +ATTENDEE:MAILTO:alice@example.com,bob@example.com,charlie@example.com,diana@example.com,eve@example.com,frank@example.com,george@example.com,helen@example.com,ivan@example.com,jane@example.com,kevin@example.com,laura@example.com,mike@example.com,nina@example.com,oscar@example.com,paul@example.com,queen@example.com,robert@example.com,sarah@example.com,tom@example.com,ursula@example.com,victor@example.com,wendy@example.com,xavier@example.com,yvonne@example.com,zack@example.com,amy@example.com,bill@example.com,carol@example.com +DTEND:20250910T190000Z +DTSTAMP:20250910T174000Z +DTSTART:20250910T180000Z +LOCATION:http://localhost:3000/test-room +ORGANIZER;CN=Test Organizer:MAILTO:organizer@example.com +SEQUENCE:1 +SUMMARY:Test Meeting with Many Attendees +UID:test-attendee-bug-event +END:VEVENT +END:VCALENDAR diff --git a/server/tests/test_attendee_parsing_bug.py b/server/tests/test_attendee_parsing_bug.py new file mode 100644 index 00000000..5e038761 --- /dev/null +++ b/server/tests/test_attendee_parsing_bug.py @@ -0,0 +1,192 @@ +import os +from unittest.mock import AsyncMock, patch + +import pytest + +from reflector.db.rooms import rooms_controller +from reflector.services.ics_sync import ICSSyncService + + +@pytest.mark.asyncio +async def test_attendee_parsing_bug(): + """ + Test that reproduces the attendee parsing bug where a string with comma-separated + emails gets parsed as individual characters instead of separate email addresses. + + The bug manifests as getting 29 attendees with emails like "M", "A", "I", etc. + instead of properly parsed email addresses. + """ + # Create a test room + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="http://test.com/test.ics", + ics_enabled=True, + ) + + # Read the test ICS file that reproduces the bug and update it with current time + from datetime import datetime, timedelta, timezone + + test_ics_path = os.path.join( + os.path.dirname(__file__), "test_attendee_parsing_bug.ics" + ) + with open(test_ics_path, "r") as f: + ics_content = f.read() + + # Replace the dates with current time + 1 hour to ensure it's within the 24h window + now = datetime.now(timezone.utc) + future_time = now + timedelta(hours=1) + end_time = future_time + timedelta(hours=1) + + # Format dates for ICS format + dtstart = future_time.strftime("%Y%m%dT%H%M%SZ") + dtend = end_time.strftime("%Y%m%dT%H%M%SZ") + dtstamp = now.strftime("%Y%m%dT%H%M%SZ") + + # Update the ICS content with current dates + ics_content = ics_content.replace("20250910T180000Z", dtstart) + ics_content = ics_content.replace("20250910T190000Z", dtend) + ics_content = ics_content.replace("20250910T174000Z", dtstamp) + + # Create sync service and mock the fetch + sync_service = ICSSyncService() + + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content + + # Debug: Parse the ICS content directly to examine attendee parsing + calendar = sync_service.fetch_service.parse_ics(ics_content) + from reflector.settings import settings + + room_url = f"{settings.UI_BASE_URL}/{room.name}" + + print(f"Room URL being used for matching: {room_url}") + print(f"ICS content:\n{ics_content}") + + events, total_events = sync_service.fetch_service.extract_room_events( + calendar, room.name, room_url + ) + + print(f"Total events in calendar: {total_events}") + print(f"Events matching room: {len(events)}") + + # Perform the sync + result = await sync_service.sync_room_calendar(room) + + # Check that the sync succeeded + assert result.get("status") == "success" + assert result.get("events_found", 0) >= 0 # Allow for debugging + + # We already have the matching events from the debug code above + assert len(events) == 1 + event = events[0] + + # This is where the bug manifests - check the attendees + attendees = event["attendees"] + + # Print attendee info for debugging + print(f"Number of attendees found: {len(attendees)}") + for i, attendee in enumerate(attendees): + print( + f"Attendee {i}: email='{attendee.get('email')}', name='{attendee.get('name')}'" + ) + + # With the fix, we should now get properly parsed email addresses + # Check that no single characters are parsed as emails + single_char_emails = [ + att for att in attendees if att.get("email") and len(att["email"]) == 1 + ] + + if single_char_emails: + print( + f"BUG DETECTED: Found {len(single_char_emails)} single-character emails:" + ) + for att in single_char_emails: + print(f" - '{att['email']}'") + + # Should have attendees but not single-character emails + assert len(attendees) > 0 + assert ( + len(single_char_emails) == 0 + ), f"Found {len(single_char_emails)} single-character emails, parsing is still buggy" + + # Check that all emails are valid (contain @ symbol) + valid_emails = [ + att for att in attendees if att.get("email") and "@" in att["email"] + ] + assert len(valid_emails) == len( + attendees + ), "Some attendees don't have valid email addresses" + + # We expect around 29 attendees (28 from the comma-separated list + 1 organizer) + assert ( + len(attendees) >= 25 + ), f"Expected around 29 attendees, got {len(attendees)}" + + +@pytest.mark.asyncio +async def test_correct_attendee_parsing(): + """ + Test what correct attendee parsing should look like. + """ + from datetime import datetime, timezone + + from icalendar import Event + + from reflector.services.ics_sync import ICSFetchService + + service = ICSFetchService() + + # Create a properly formatted event with multiple attendees + event = Event() + event.add("uid", "test-correct-attendees") + event.add("summary", "Test Meeting") + event.add("location", "http://test.com/test") + event.add("dtstart", datetime.now(timezone.utc)) + event.add("dtend", datetime.now(timezone.utc)) + + # Add attendees the correct way (separate ATTENDEE lines) + event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"}) + event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"}) + event.add("attendee", "mailto:charlie@example.com", parameters={"CN": "Charlie"}) + event.add( + "organizer", "mailto:organizer@example.com", parameters={"CN": "Organizer"} + ) + + # Parse the event + result = service._parse_event(event) + + assert result is not None + attendees = result["attendees"] + + # Should have 4 attendees (3 attendees + 1 organizer) + assert len(attendees) == 4 + + # Check that all emails are valid email addresses + emails = [att["email"] for att in attendees if att.get("email")] + expected_emails = [ + "alice@example.com", + "bob@example.com", + "charlie@example.com", + "organizer@example.com", + ] + + for email in emails: + assert "@" in email, f"Invalid email format: {email}" + assert len(email) > 5, f"Email too short: {email}" + + # Check that we have the expected emails + assert "alice@example.com" in emails + assert "bob@example.com" in emails + assert "charlie@example.com" in emails + assert "organizer@example.com" in emails diff --git a/server/tests/test_calendar_event.py b/server/tests/test_calendar_event.py new file mode 100644 index 00000000..ece5f56a --- /dev/null +++ b/server/tests/test_calendar_event.py @@ -0,0 +1,424 @@ +""" +Tests for CalendarEvent model. +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.rooms import rooms_controller + + +@pytest.mark.asyncio +async def test_calendar_event_create(): + """Test creating a calendar event.""" + # Create a room first + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + # Create calendar event + now = datetime.now(timezone.utc) + event = CalendarEvent( + room_id=room.id, + ics_uid="test-event-123", + title="Team Meeting", + description="Weekly team sync", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + location=f"https://example.com/{room.name}", + attendees=[ + {"email": "alice@example.com", "name": "Alice", "status": "ACCEPTED"}, + {"email": "bob@example.com", "name": "Bob", "status": "TENTATIVE"}, + ], + ) + + # Save event + saved_event = await calendar_events_controller.upsert(event) + + assert saved_event.ics_uid == "test-event-123" + assert saved_event.title == "Team Meeting" + assert saved_event.room_id == room.id + assert len(saved_event.attendees) == 2 + + +@pytest.mark.asyncio +async def test_calendar_event_get_by_room(): + """Test getting calendar events for a room.""" + # Create room + room = await rooms_controller.add( + name="events-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create multiple events + for i in range(3): + event = CalendarEvent( + room_id=room.id, + ics_uid=f"event-{i}", + title=f"Meeting {i}", + start_time=now + timedelta(hours=i), + end_time=now + timedelta(hours=i + 1), + ) + await calendar_events_controller.upsert(event) + + # Get events for room + events = await calendar_events_controller.get_by_room(room.id) + + assert len(events) == 3 + assert all(e.room_id == room.id for e in events) + assert events[0].title == "Meeting 0" + assert events[1].title == "Meeting 1" + assert events[2].title == "Meeting 2" + + +@pytest.mark.asyncio +async def test_calendar_event_get_upcoming(): + """Test getting upcoming events within time window.""" + # Create room + room = await rooms_controller.add( + name="upcoming-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create events at different times + # Past event (should not be included) + past_event = CalendarEvent( + room_id=room.id, + ics_uid="past-event", + title="Past Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + ) + await calendar_events_controller.upsert(past_event) + + # Upcoming event within 30 minutes + upcoming_event = CalendarEvent( + room_id=room.id, + ics_uid="upcoming-event", + title="Upcoming Meeting", + start_time=now + timedelta(minutes=15), + end_time=now + timedelta(minutes=45), + ) + await calendar_events_controller.upsert(upcoming_event) + + # Currently happening event (started 10 minutes ago, ends in 20 minutes) + current_event = CalendarEvent( + room_id=room.id, + ics_uid="current-event", + title="Current Meeting", + start_time=now - timedelta(minutes=10), + end_time=now + timedelta(minutes=20), + ) + await calendar_events_controller.upsert(current_event) + + # Future event beyond 30 minutes + future_event = CalendarEvent( + room_id=room.id, + ics_uid="future-event", + title="Future Meeting", + start_time=now + timedelta(hours=2), + end_time=now + timedelta(hours=3), + ) + await calendar_events_controller.upsert(future_event) + + # Get upcoming events (default 120 minutes) - should include current, upcoming, and future + upcoming = await calendar_events_controller.get_upcoming(room.id) + + assert len(upcoming) == 3 + # Events should be sorted by start_time (current event first, then upcoming, then future) + assert upcoming[0].ics_uid == "current-event" + assert upcoming[1].ics_uid == "upcoming-event" + assert upcoming[2].ics_uid == "future-event" + + # Get upcoming with custom window + upcoming_extended = await calendar_events_controller.get_upcoming( + room.id, minutes_ahead=180 + ) + + assert len(upcoming_extended) == 3 + # Events should be sorted by start_time + assert upcoming_extended[0].ics_uid == "current-event" + assert upcoming_extended[1].ics_uid == "upcoming-event" + assert upcoming_extended[2].ics_uid == "future-event" + + +@pytest.mark.asyncio +async def test_calendar_event_get_upcoming_includes_currently_happening(): + """Test that get_upcoming includes currently happening events but excludes ended events.""" + # Create room + room = await rooms_controller.add( + name="current-happening-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Event that ended in the past (should NOT be included) + past_ended_event = CalendarEvent( + room_id=room.id, + ics_uid="past-ended-event", + title="Past Ended Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(minutes=30), + ) + await calendar_events_controller.upsert(past_ended_event) + + # Event currently happening (started 10 minutes ago, ends in 20 minutes) - SHOULD be included + currently_happening_event = CalendarEvent( + room_id=room.id, + ics_uid="currently-happening", + title="Currently Happening Meeting", + start_time=now - timedelta(minutes=10), + end_time=now + timedelta(minutes=20), + ) + await calendar_events_controller.upsert(currently_happening_event) + + # Event starting soon (in 5 minutes) - SHOULD be included + upcoming_soon_event = CalendarEvent( + room_id=room.id, + ics_uid="upcoming-soon", + title="Upcoming Soon Meeting", + start_time=now + timedelta(minutes=5), + end_time=now + timedelta(minutes=35), + ) + await calendar_events_controller.upsert(upcoming_soon_event) + + # Get upcoming events + upcoming = await calendar_events_controller.get_upcoming(room.id, minutes_ahead=30) + + # Should only include currently happening and upcoming soon events + assert len(upcoming) == 2 + assert upcoming[0].ics_uid == "currently-happening" + assert upcoming[1].ics_uid == "upcoming-soon" + + +@pytest.mark.asyncio +async def test_calendar_event_upsert(): + """Test upserting (create/update) calendar events.""" + # Create room + room = await rooms_controller.add( + name="upsert-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create new event + event = CalendarEvent( + room_id=room.id, + ics_uid="upsert-test", + title="Original Title", + start_time=now, + end_time=now + timedelta(hours=1), + ) + + created = await calendar_events_controller.upsert(event) + assert created.title == "Original Title" + + # Update existing event + event.title = "Updated Title" + event.description = "Added description" + + updated = await calendar_events_controller.upsert(event) + assert updated.title == "Updated Title" + assert updated.description == "Added description" + assert updated.ics_uid == "upsert-test" + + # Verify only one event exists + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].title == "Updated Title" + + +@pytest.mark.asyncio +async def test_calendar_event_soft_delete(): + """Test soft deleting events no longer in calendar.""" + # Create room + room = await rooms_controller.add( + name="delete-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create multiple events + for i in range(4): + event = CalendarEvent( + room_id=room.id, + ics_uid=f"event-{i}", + title=f"Meeting {i}", + start_time=now + timedelta(hours=i), + end_time=now + timedelta(hours=i + 1), + ) + await calendar_events_controller.upsert(event) + + # Soft delete events not in current list + current_ids = ["event-0", "event-2"] # Keep events 0 and 2 + deleted_count = await calendar_events_controller.soft_delete_missing( + room.id, current_ids + ) + + assert deleted_count == 2 # Should delete events 1 and 3 + + # Get non-deleted events + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + assert len(events) == 2 + assert {e.ics_uid for e in events} == {"event-0", "event-2"} + + # Get all events including deleted + all_events = await calendar_events_controller.get_by_room( + room.id, include_deleted=True + ) + assert len(all_events) == 4 + + +@pytest.mark.asyncio +async def test_calendar_event_past_events_not_deleted(): + """Test that past events are not soft deleted.""" + # Create room + room = await rooms_controller.add( + name="past-events-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + now = datetime.now(timezone.utc) + + # Create past event + past_event = CalendarEvent( + room_id=room.id, + ics_uid="past-event", + title="Past Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + ) + await calendar_events_controller.upsert(past_event) + + # Create future event + future_event = CalendarEvent( + room_id=room.id, + ics_uid="future-event", + title="Future Meeting", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + ) + await calendar_events_controller.upsert(future_event) + + # Try to soft delete all events (only future should be deleted) + deleted_count = await calendar_events_controller.soft_delete_missing(room.id, []) + + assert deleted_count == 1 # Only future event deleted + + # Verify past event still exists + events = await calendar_events_controller.get_by_room( + room.id, include_deleted=False + ) + assert len(events) == 1 + assert events[0].ics_uid == "past-event" + + +@pytest.mark.asyncio +async def test_calendar_event_with_raw_ics_data(): + """Test storing raw ICS data with calendar event.""" + # Create room + room = await rooms_controller.add( + name="raw-ics-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + raw_ics = """BEGIN:VEVENT +UID:test-raw-123 +SUMMARY:Test Event +DTSTART:20240101T100000Z +DTEND:20240101T110000Z +END:VEVENT""" + + event = CalendarEvent( + room_id=room.id, + ics_uid="test-raw-123", + title="Test Event", + start_time=datetime.now(timezone.utc), + end_time=datetime.now(timezone.utc) + timedelta(hours=1), + ics_raw_data=raw_ics, + ) + + saved = await calendar_events_controller.upsert(event) + + assert saved.ics_raw_data == raw_ics + + # Retrieve and verify + retrieved = await calendar_events_controller.get_by_ics_uid(room.id, "test-raw-123") + assert retrieved is not None + assert retrieved.ics_raw_data == raw_ics diff --git a/server/tests/test_cleanup.py b/server/tests/test_cleanup.py new file mode 100644 index 00000000..0c968941 --- /dev/null +++ b/server/tests/test_cleanup.py @@ -0,0 +1,281 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from reflector.db.recordings import Recording, recordings_controller +from reflector.db.transcripts import SourceKind, transcripts_controller +from reflector.worker.cleanup import cleanup_old_public_data + + +@pytest.mark.asyncio +async def test_cleanup_old_public_data_skips_when_not_public(): + """Test that cleanup is skipped when PUBLIC_MODE is False.""" + with patch("reflector.worker.cleanup.settings") as mock_settings: + mock_settings.PUBLIC_MODE = False + + result = await cleanup_old_public_data() + + # Should return early without doing anything + assert result is None + + +@pytest.mark.asyncio +async def test_cleanup_old_public_data_deletes_old_anonymous_transcripts(): + """Test that old anonymous transcripts are deleted.""" + # Create old and new anonymous transcripts + old_date = datetime.now(timezone.utc) - timedelta(days=8) + new_date = datetime.now(timezone.utc) - timedelta(days=2) + + # Create old anonymous transcript (should be deleted) + old_transcript = await transcripts_controller.add( + name="Old Anonymous Transcript", + source_kind=SourceKind.FILE, + user_id=None, # Anonymous + ) + # Manually update created_at to be old + from reflector.db import get_database + from reflector.db.transcripts import transcripts + + await get_database().execute( + transcripts.update() + .where(transcripts.c.id == old_transcript.id) + .values(created_at=old_date) + ) + + # Create new anonymous transcript (should NOT be deleted) + new_transcript = await transcripts_controller.add( + name="New Anonymous Transcript", + source_kind=SourceKind.FILE, + user_id=None, # Anonymous + ) + + # Create old transcript with user (should NOT be deleted) + old_user_transcript = await transcripts_controller.add( + name="Old User Transcript", + source_kind=SourceKind.FILE, + user_id="user123", + ) + await get_database().execute( + transcripts.update() + .where(transcripts.c.id == old_user_transcript.id) + .values(created_at=old_date) + ) + + with patch("reflector.worker.cleanup.settings") as mock_settings: + mock_settings.PUBLIC_MODE = True + mock_settings.PUBLIC_DATA_RETENTION_DAYS = 7 + + # Mock the storage deletion + with patch("reflector.db.transcripts.get_transcripts_storage") as mock_storage: + mock_storage.return_value.delete_file = AsyncMock() + + result = await cleanup_old_public_data() + + # Check results + assert result["transcripts_deleted"] == 1 + assert result["errors"] == [] + + # Verify old anonymous transcript was deleted + assert await transcripts_controller.get_by_id(old_transcript.id) is None + + # Verify new anonymous transcript still exists + assert await transcripts_controller.get_by_id(new_transcript.id) is not None + + # Verify user transcript still exists + assert await transcripts_controller.get_by_id(old_user_transcript.id) is not None + + +@pytest.mark.asyncio +async def test_cleanup_deletes_associated_meeting_and_recording(): + """Test that meetings and recordings associated with old transcripts are deleted.""" + from reflector.db import get_database + from reflector.db.meetings import meetings + from reflector.db.transcripts import transcripts + + old_date = datetime.now(timezone.utc) - timedelta(days=8) + + # Create a meeting + meeting_id = "test-meeting-for-transcript" + await get_database().execute( + meetings.insert().values( + id=meeting_id, + room_name="Meeting with Transcript", + room_url="https://example.com/meeting", + host_room_url="https://example.com/meeting-host", + start_date=old_date, + end_date=old_date + timedelta(hours=1), + room_id=None, + ) + ) + + # Create a recording + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="test-recording.mp4", + recorded_at=old_date, + ) + ) + + # Create an old transcript with both meeting and recording + old_transcript = await transcripts_controller.add( + name="Old Transcript with Meeting and Recording", + source_kind=SourceKind.ROOM, + user_id=None, + meeting_id=meeting_id, + recording_id=recording.id, + ) + + # Update created_at to be old + await get_database().execute( + transcripts.update() + .where(transcripts.c.id == old_transcript.id) + .values(created_at=old_date) + ) + + with patch("reflector.worker.cleanup.settings") as mock_settings: + mock_settings.PUBLIC_MODE = True + mock_settings.PUBLIC_DATA_RETENTION_DAYS = 7 + + # Mock storage deletion + with patch("reflector.worker.cleanup.get_transcripts_storage") as mock_storage: + mock_storage.return_value.delete_file = AsyncMock() + + result = await cleanup_old_public_data() + + # Check results + assert result["transcripts_deleted"] == 1 + assert result["meetings_deleted"] == 1 + assert result["recordings_deleted"] == 1 + assert result["errors"] == [] + + # Verify transcript was deleted + assert await transcripts_controller.get_by_id(old_transcript.id) is None + + # Verify meeting was deleted + query = meetings.select().where(meetings.c.id == meeting_id) + meeting_result = await get_database().fetch_one(query) + assert meeting_result is None + + # Verify recording was deleted + assert await recordings_controller.get_by_id(recording.id) is None + + +@pytest.mark.asyncio +async def test_cleanup_handles_errors_gracefully(): + """Test that cleanup continues even when individual deletions fail.""" + old_date = datetime.now(timezone.utc) - timedelta(days=8) + + # Create multiple old transcripts + transcript1 = await transcripts_controller.add( + name="Transcript 1", + source_kind=SourceKind.FILE, + user_id=None, + ) + transcript2 = await transcripts_controller.add( + name="Transcript 2", + source_kind=SourceKind.FILE, + user_id=None, + ) + + # Update created_at to be old + from reflector.db import get_database + from reflector.db.transcripts import transcripts + + for t_id in [transcript1.id, transcript2.id]: + await get_database().execute( + transcripts.update() + .where(transcripts.c.id == t_id) + .values(created_at=old_date) + ) + + with patch("reflector.worker.cleanup.settings") as mock_settings: + mock_settings.PUBLIC_MODE = True + mock_settings.PUBLIC_DATA_RETENTION_DAYS = 7 + + # Mock remove_by_id to fail for the first transcript + original_remove = transcripts_controller.remove_by_id + call_count = 0 + + async def mock_remove_by_id(transcript_id, user_id=None): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise Exception("Simulated deletion error") + return await original_remove(transcript_id, user_id) + + with patch.object( + transcripts_controller, "remove_by_id", side_effect=mock_remove_by_id + ): + result = await cleanup_old_public_data() + + # Should have one successful deletion and one error + assert result["transcripts_deleted"] == 1 + assert len(result["errors"]) == 1 + assert "Failed to delete transcript" in result["errors"][0] + + +@pytest.mark.asyncio +async def test_meeting_consent_cascade_delete(): + """Test that meeting_consent records are automatically deleted when meeting is deleted.""" + from reflector.db import get_database + from reflector.db.meetings import ( + meeting_consent, + meeting_consent_controller, + meetings, + ) + + # Create a meeting + meeting_id = "test-cascade-meeting" + await get_database().execute( + meetings.insert().values( + id=meeting_id, + room_name="Test Meeting for CASCADE", + room_url="https://example.com/cascade-test", + host_room_url="https://example.com/cascade-test-host", + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc) + timedelta(hours=1), + room_id=None, + ) + ) + + # Create consent records for this meeting + consent1_id = "consent-1" + consent2_id = "consent-2" + + await get_database().execute( + meeting_consent.insert().values( + id=consent1_id, + meeting_id=meeting_id, + user_id="user1", + consent_given=True, + consent_timestamp=datetime.now(timezone.utc), + ) + ) + + await get_database().execute( + meeting_consent.insert().values( + id=consent2_id, + meeting_id=meeting_id, + user_id="user2", + consent_given=False, + consent_timestamp=datetime.now(timezone.utc), + ) + ) + + # Verify consent records exist + consents = await meeting_consent_controller.get_by_meeting_id(meeting_id) + assert len(consents) == 2 + + # Delete the meeting + await get_database().execute(meetings.delete().where(meetings.c.id == meeting_id)) + + # Verify meeting is deleted + query = meetings.select().where(meetings.c.id == meeting_id) + result = await get_database().fetch_one(query) + assert result is None + + # Verify consent records are automatically deleted (CASCADE DELETE) + consents_after = await meeting_consent_controller.get_by_meeting_id(meeting_id) + assert len(consents_after) == 0 diff --git a/server/tests/test_consent_multitrack.py b/server/tests/test_consent_multitrack.py new file mode 100644 index 00000000..15948708 --- /dev/null +++ b/server/tests/test_consent_multitrack.py @@ -0,0 +1,330 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from reflector.db.meetings import ( + MeetingConsent, + meeting_consent_controller, + meetings_controller, +) +from reflector.db.recordings import Recording, recordings_controller +from reflector.db.rooms import rooms_controller +from reflector.db.transcripts import SourceKind, transcripts_controller +from reflector.pipelines.main_live_pipeline import cleanup_consent + + +@pytest.mark.asyncio +async def test_consent_cleanup_deletes_multitrack_files(): + room = await rooms_controller.add( + name="Test Room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic", + is_shared=False, + platform="daily", + ) + + # Create meeting + meeting = await meetings_controller.create( + id="test-multitrack-meeting", + room_name="test-room-20250101120000", + room_url="https://test.daily.co/test-room", + host_room_url="https://test.daily.co/test-room", + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc), + room=room, + ) + + track_keys = [ + "recordings/test-room-20250101120000/track-0.webm", + "recordings/test-room-20250101120000/track-1.webm", + "recordings/test-room-20250101120000/track-2.webm", + ] + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="recordings/test-room-20250101120000", # Folder path + recorded_at=datetime.now(timezone.utc), + meeting_id=meeting.id, + track_keys=track_keys, + ) + ) + + # Create transcript + transcript = await transcripts_controller.add( + name="Test Multitrack Transcript", + source_kind=SourceKind.ROOM, + recording_id=recording.id, + meeting_id=meeting.id, + ) + + # Add consent denial + await meeting_consent_controller.upsert( + MeetingConsent( + meeting_id=meeting.id, + user_id="test-user", + consent_given=False, + consent_timestamp=datetime.now(timezone.utc), + ) + ) + + # Mock get_transcripts_storage (master credentials with bucket override) + with patch( + "reflector.pipelines.main_live_pipeline.get_transcripts_storage" + ) as mock_get_transcripts_storage: + mock_master_storage = MagicMock() + mock_master_storage.delete_file = AsyncMock() + mock_get_transcripts_storage.return_value = mock_master_storage + + await cleanup_consent(transcript_id=transcript.id) + + # Verify master storage was used with bucket override for all track keys + assert mock_master_storage.delete_file.call_count == 3 + deleted_keys = [] + for call_args in mock_master_storage.delete_file.call_args_list: + key = call_args[0][0] + bucket_kwarg = call_args[1].get("bucket") + deleted_keys.append(key) + assert bucket_kwarg == "test-bucket" # Verify bucket override! + assert set(deleted_keys) == set(track_keys) + + updated_transcript = await transcripts_controller.get_by_id(transcript.id) + assert updated_transcript.audio_deleted is True + + +@pytest.mark.asyncio +async def test_consent_cleanup_handles_missing_track_keys(): + room = await rooms_controller.add( + name="Test Room 2", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic", + is_shared=False, + platform="daily", + ) + + # Create meeting + meeting = await meetings_controller.create( + id="test-multitrack-meeting-2", + room_name="test-room-20250101120001", + room_url="https://test.daily.co/test-room-2", + host_room_url="https://test.daily.co/test-room-2", + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc), + room=room, + ) + + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="recordings/old-style-recording.mp4", + recorded_at=datetime.now(timezone.utc), + meeting_id=meeting.id, + track_keys=None, + ) + ) + + transcript = await transcripts_controller.add( + name="Test Old-Style Transcript", + source_kind=SourceKind.ROOM, + recording_id=recording.id, + meeting_id=meeting.id, + ) + + # Add consent denial + await meeting_consent_controller.upsert( + MeetingConsent( + meeting_id=meeting.id, + user_id="test-user-2", + consent_given=False, + consent_timestamp=datetime.now(timezone.utc), + ) + ) + + # Mock get_transcripts_storage (master credentials with bucket override) + with patch( + "reflector.pipelines.main_live_pipeline.get_transcripts_storage" + ) as mock_get_transcripts_storage: + mock_master_storage = MagicMock() + mock_master_storage.delete_file = AsyncMock() + mock_get_transcripts_storage.return_value = mock_master_storage + + await cleanup_consent(transcript_id=transcript.id) + + # Verify master storage was used with bucket override + assert mock_master_storage.delete_file.call_count == 1 + call_args = mock_master_storage.delete_file.call_args + assert call_args[0][0] == recording.object_key + assert call_args[1].get("bucket") == "test-bucket" # Verify bucket override! + + +@pytest.mark.asyncio +async def test_consent_cleanup_empty_track_keys_falls_back(): + room = await rooms_controller.add( + name="Test Room 3", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic", + is_shared=False, + platform="daily", + ) + + # Create meeting + meeting = await meetings_controller.create( + id="test-multitrack-meeting-3", + room_name="test-room-20250101120002", + room_url="https://test.daily.co/test-room-3", + host_room_url="https://test.daily.co/test-room-3", + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc), + room=room, + ) + + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="recordings/fallback-recording.mp4", + recorded_at=datetime.now(timezone.utc), + meeting_id=meeting.id, + track_keys=[], + ) + ) + + transcript = await transcripts_controller.add( + name="Test Empty Track Keys Transcript", + source_kind=SourceKind.ROOM, + recording_id=recording.id, + meeting_id=meeting.id, + ) + + # Add consent denial + await meeting_consent_controller.upsert( + MeetingConsent( + meeting_id=meeting.id, + user_id="test-user-3", + consent_given=False, + consent_timestamp=datetime.now(timezone.utc), + ) + ) + + # Mock get_transcripts_storage (master credentials with bucket override) + with patch( + "reflector.pipelines.main_live_pipeline.get_transcripts_storage" + ) as mock_get_transcripts_storage: + mock_master_storage = MagicMock() + mock_master_storage.delete_file = AsyncMock() + mock_get_transcripts_storage.return_value = mock_master_storage + + # Run cleanup + await cleanup_consent(transcript_id=transcript.id) + + # Verify master storage was used with bucket override + assert mock_master_storage.delete_file.call_count == 1 + call_args = mock_master_storage.delete_file.call_args + assert call_args[0][0] == recording.object_key + assert call_args[1].get("bucket") == "test-bucket" # Verify bucket override! + + +@pytest.mark.asyncio +async def test_consent_cleanup_partial_failure_doesnt_mark_deleted(): + room = await rooms_controller.add( + name="Test Room 4", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic", + is_shared=False, + platform="daily", + ) + + # Create meeting + meeting = await meetings_controller.create( + id="test-multitrack-meeting-4", + room_name="test-room-20250101120003", + room_url="https://test.daily.co/test-room-4", + host_room_url="https://test.daily.co/test-room-4", + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc), + room=room, + ) + + track_keys = [ + "recordings/test-room-20250101120003/track-0.webm", + "recordings/test-room-20250101120003/track-1.webm", + "recordings/test-room-20250101120003/track-2.webm", + ] + recording = await recordings_controller.create( + Recording( + bucket_name="test-bucket", + object_key="recordings/test-room-20250101120003", + recorded_at=datetime.now(timezone.utc), + meeting_id=meeting.id, + track_keys=track_keys, + ) + ) + + # Create transcript + transcript = await transcripts_controller.add( + name="Test Partial Failure Transcript", + source_kind=SourceKind.ROOM, + recording_id=recording.id, + meeting_id=meeting.id, + ) + + # Add consent denial + await meeting_consent_controller.upsert( + MeetingConsent( + meeting_id=meeting.id, + user_id="test-user-4", + consent_given=False, + consent_timestamp=datetime.now(timezone.utc), + ) + ) + + # Mock get_transcripts_storage (master credentials with bucket override) with partial failure + with patch( + "reflector.pipelines.main_live_pipeline.get_transcripts_storage" + ) as mock_get_transcripts_storage: + mock_master_storage = MagicMock() + + call_count = 0 + + async def delete_side_effect(key, bucket=None): + nonlocal call_count + call_count += 1 + if call_count == 2: + raise Exception("S3 deletion failed") + + mock_master_storage.delete_file = AsyncMock(side_effect=delete_side_effect) + mock_get_transcripts_storage.return_value = mock_master_storage + + await cleanup_consent(transcript_id=transcript.id) + + # Verify master storage was called with bucket override + assert mock_master_storage.delete_file.call_count == 3 + + updated_transcript = await transcripts_controller.get_by_id(transcript.id) + assert ( + updated_transcript.audio_deleted is None + or updated_transcript.audio_deleted is False + ) diff --git a/server/tests/test_ics_background_tasks.py b/server/tests/test_ics_background_tasks.py new file mode 100644 index 00000000..c2bf5c87 --- /dev/null +++ b/server/tests/test_ics_background_tasks.py @@ -0,0 +1,255 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from icalendar import Calendar, Event + +from reflector.db import get_database +from reflector.db.calendar_events import calendar_events_controller +from reflector.db.rooms import rooms, rooms_controller +from reflector.services.ics_sync import ics_sync_service +from reflector.worker.ics_sync import ( + _should_sync, + sync_room_ics, +) + + +@pytest.mark.asyncio +async def test_sync_room_ics_task(): + room = await rooms_controller.add( + name="task-test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/task.ics", + ics_enabled=True, + ) + + cal = Calendar() + event = Event() + event.add("uid", "task-event-1") + event.add("summary", "Task Test Meeting") + from reflector.settings import settings + + event.add("location", f"{settings.UI_BASE_URL}/{room.name}") + now = datetime.now(timezone.utc) + event.add("dtstart", now + timedelta(hours=1)) + event.add("dtend", now + timedelta(hours=2)) + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + + with patch( + "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content + + # Call the service directly instead of the Celery task to avoid event loop issues + await ics_sync_service.sync_room_calendar(room) + + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].ics_uid == "task-event-1" + + +@pytest.mark.asyncio +async def test_sync_room_ics_disabled(): + room = await rooms_controller.add( + name="disabled-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_enabled=False, + ) + + # Test that disabled rooms are skipped by the service + result = await ics_sync_service.sync_room_calendar(room) + + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 0 + + +@pytest.mark.asyncio +async def test_sync_all_ics_calendars(): + room1 = await rooms_controller.add( + name="sync-all-1", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/1.ics", + ics_enabled=True, + ) + + room2 = await rooms_controller.add( + name="sync-all-2", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/2.ics", + ics_enabled=True, + ) + + room3 = await rooms_controller.add( + name="sync-all-3", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_enabled=False, + ) + + with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay: + # Directly call the sync_all logic without the Celery wrapper + query = rooms.select().where( + rooms.c.ics_enabled == True, rooms.c.ics_url != None + ) + all_rooms = await get_database().fetch_all(query) + + for room_data in all_rooms: + room_id = room_data["id"] + room = await rooms_controller.get_by_id(room_id) + if room and _should_sync(room): + sync_room_ics.delay(room_id) + + assert mock_delay.call_count == 2 + called_room_ids = [call.args[0] for call in mock_delay.call_args_list] + assert room1.id in called_room_ids + assert room2.id in called_room_ids + assert room3.id not in called_room_ids + + +@pytest.mark.asyncio +async def test_should_sync_logic(): + room = MagicMock() + + room.ics_last_sync = None + assert _should_sync(room) is True + + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100) + room.ics_fetch_interval = 300 + assert _should_sync(room) is False + + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400) + room.ics_fetch_interval = 300 + assert _should_sync(room) is True + + +@pytest.mark.asyncio +async def test_sync_respects_fetch_interval(): + now = datetime.now(timezone.utc) + + room1 = await rooms_controller.add( + name="interval-test-1", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/interval.ics", + ics_enabled=True, + ics_fetch_interval=300, + ) + + await rooms_controller.update( + room1, + {"ics_last_sync": now - timedelta(seconds=100)}, + ) + + room2 = await rooms_controller.add( + name="interval-test-2", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/interval2.ics", + ics_enabled=True, + ics_fetch_interval=60, + ) + + await rooms_controller.update( + room2, + {"ics_last_sync": now - timedelta(seconds=100)}, + ) + + with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay: + # Test the sync logic without the Celery wrapper + query = rooms.select().where( + rooms.c.ics_enabled == True, rooms.c.ics_url != None + ) + all_rooms = await get_database().fetch_all(query) + + for room_data in all_rooms: + room_id = room_data["id"] + room = await rooms_controller.get_by_id(room_id) + if room and _should_sync(room): + sync_room_ics.delay(room_id) + + assert mock_delay.call_count == 1 + assert mock_delay.call_args[0][0] == room2.id + + +@pytest.mark.asyncio +async def test_sync_handles_errors_gracefully(): + room = await rooms_controller.add( + name="error-task-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/error.ics", + ics_enabled=True, + ) + + with patch( + "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.side_effect = Exception("Network error") + + # Call the service directly to test error handling + result = await ics_sync_service.sync_room_calendar(room) + assert result["status"] == "error" + + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 0 diff --git a/server/tests/test_ics_sync.py b/server/tests/test_ics_sync.py new file mode 100644 index 00000000..e448dd7d --- /dev/null +++ b/server/tests/test_ics_sync.py @@ -0,0 +1,290 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from icalendar import Calendar, Event + +from reflector.db.calendar_events import calendar_events_controller +from reflector.db.rooms import rooms_controller +from reflector.services.ics_sync import ICSFetchService, ICSSyncService + + +@pytest.mark.asyncio +async def test_ics_fetch_service_event_matching(): + service = ICSFetchService() + room_name = "test-room" + room_url = "https://example.com/test-room" + + # Create test event + event = Event() + event.add("uid", "test-123") + event.add("summary", "Test Meeting") + + # Test matching with full URL in location + event.add("location", "https://example.com/test-room") + assert service._event_matches_room(event, room_name, room_url) is True + + # Test non-matching with URL without protocol (exact matching only now) + event["location"] = "example.com/test-room" + assert service._event_matches_room(event, room_name, room_url) is False + + # Test matching in description + event["location"] = "Conference Room A" + event.add("description", f"Join at {room_url}") + assert service._event_matches_room(event, room_name, room_url) is True + + # Test non-matching + event["location"] = "Different Room" + event["description"] = "No room URL here" + assert service._event_matches_room(event, room_name, room_url) is False + + # Test partial paths should NOT match anymore + event["location"] = "/test-room" + assert service._event_matches_room(event, room_name, room_url) is False + + event["location"] = f"Room: {room_name}" + assert service._event_matches_room(event, room_name, room_url) is False + + +@pytest.mark.asyncio +async def test_ics_fetch_service_parse_event(): + service = ICSFetchService() + + # Create test event + event = Event() + event.add("uid", "test-456") + event.add("summary", "Team Standup") + event.add("description", "Daily team sync") + event.add("location", "https://example.com/standup") + + now = datetime.now(timezone.utc) + event.add("dtstart", now) + event.add("dtend", now + timedelta(hours=1)) + + # Add attendees + event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"}) + event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"}) + event.add("organizer", "mailto:carol@example.com", parameters={"CN": "Carol"}) + + # Parse event + result = service._parse_event(event) + + assert result is not None + assert result["ics_uid"] == "test-456" + assert result["title"] == "Team Standup" + assert result["description"] == "Daily team sync" + assert result["location"] == "https://example.com/standup" + assert len(result["attendees"]) == 3 # 2 attendees + 1 organizer + + +@pytest.mark.asyncio +async def test_ics_fetch_service_extract_room_events(): + service = ICSFetchService() + room_name = "meeting" + room_url = "https://example.com/meeting" + + # Create calendar with multiple events + cal = Calendar() + + # Event 1: Matches room + event1 = Event() + event1.add("uid", "match-1") + event1.add("summary", "Planning Meeting") + event1.add("location", room_url) + now = datetime.now(timezone.utc) + event1.add("dtstart", now + timedelta(hours=2)) + event1.add("dtend", now + timedelta(hours=3)) + cal.add_component(event1) + + # Event 2: Doesn't match room + event2 = Event() + event2.add("uid", "no-match") + event2.add("summary", "Other Meeting") + event2.add("location", "https://example.com/other") + event2.add("dtstart", now + timedelta(hours=4)) + event2.add("dtend", now + timedelta(hours=5)) + cal.add_component(event2) + + # Event 3: Matches room in description + event3 = Event() + event3.add("uid", "match-2") + event3.add("summary", "Review Session") + event3.add("description", f"Meeting link: {room_url}") + event3.add("dtstart", now + timedelta(hours=6)) + event3.add("dtend", now + timedelta(hours=7)) + cal.add_component(event3) + + # Event 4: Cancelled event (should be skipped) + event4 = Event() + event4.add("uid", "cancelled") + event4.add("summary", "Cancelled Meeting") + event4.add("location", room_url) + event4.add("status", "CANCELLED") + event4.add("dtstart", now + timedelta(hours=8)) + event4.add("dtend", now + timedelta(hours=9)) + cal.add_component(event4) + + # Extract events + events, total_events = service.extract_room_events(cal, room_name, room_url) + + assert len(events) == 2 + assert total_events == 3 # 3 events in time window (excluding cancelled) + assert events[0]["ics_uid"] == "match-1" + assert events[1]["ics_uid"] == "match-2" + + +@pytest.mark.asyncio +async def test_ics_sync_service_sync_room_calendar(): + # Create room + room = await rooms_controller.add( + name="sync-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/test.ics", + ics_enabled=True, + ) + + # Mock ICS content + cal = Calendar() + event = Event() + event.add("uid", "sync-event-1") + event.add("summary", "Sync Test Meeting") + # Use the actual UI_BASE_URL from settings + from reflector.settings import settings + + event.add("location", f"{settings.UI_BASE_URL}/{room.name}") + now = datetime.now(timezone.utc) + event.add("dtstart", now + timedelta(hours=1)) + event.add("dtend", now + timedelta(hours=2)) + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + + # Create sync service and mock fetch + sync_service = ICSSyncService() + + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content + + # First sync + result = await sync_service.sync_room_calendar(room) + + assert result["status"] == "success" + assert result["events_found"] == 1 + assert result["events_created"] == 1 + assert result["events_updated"] == 0 + assert result["events_deleted"] == 0 + + # Verify event was created + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].ics_uid == "sync-event-1" + assert events[0].title == "Sync Test Meeting" + + # Second sync with same content (should be unchanged) + # Refresh room to get updated etag and force sync by setting old sync time + room = await rooms_controller.get_by_id(room.id) + await rooms_controller.update( + room, {"ics_last_sync": datetime.now(timezone.utc) - timedelta(minutes=10)} + ) + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "unchanged" + + # Third sync with updated event + event["summary"] = "Updated Meeting Title" + cal = Calendar() + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + mock_fetch.return_value = ics_content + + # Force sync by clearing etag + await rooms_controller.update(room, {"ics_last_etag": None}) + + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "success" + assert result["events_created"] == 0 + assert result["events_updated"] == 1 + + # Verify event was updated + events = await calendar_events_controller.get_by_room(room.id) + assert len(events) == 1 + assert events[0].title == "Updated Meeting Title" + + +@pytest.mark.asyncio +async def test_ics_sync_service_should_sync(): + service = ICSSyncService() + + # Room never synced + room = MagicMock() + room.ics_last_sync = None + room.ics_fetch_interval = 300 + assert service._should_sync(room) is True + + # Room synced recently + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100) + assert service._should_sync(room) is False + + # Room sync due + room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400) + assert service._should_sync(room) is True + + +@pytest.mark.asyncio +async def test_ics_sync_service_skip_disabled(): + service = ICSSyncService() + + # Room with ICS disabled + room = MagicMock() + room.ics_enabled = False + room.ics_url = "https://calendar.example.com/test.ics" + + result = await service.sync_room_calendar(room) + assert result["status"] == "skipped" + assert result["reason"] == "ICS not configured" + + # Room without URL + room.ics_enabled = True + room.ics_url = None + + result = await service.sync_room_calendar(room) + assert result["status"] == "skipped" + assert result["reason"] == "ICS not configured" + + +@pytest.mark.asyncio +async def test_ics_sync_service_error_handling(): + # Create room + room = await rooms_controller.add( + name="error-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/error.ics", + ics_enabled=True, + ) + + sync_service = ICSSyncService() + + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.side_effect = Exception("Network error") + + result = await sync_service.sync_room_calendar(room) + assert result["status"] == "error" + assert "Network error" in result["error"] diff --git a/server/tests/test_model_api_diarization.py b/server/tests/test_model_api_diarization.py new file mode 100644 index 00000000..8ae719ff --- /dev/null +++ b/server/tests/test_model_api_diarization.py @@ -0,0 +1,63 @@ +""" +Tests for diarization Model API endpoint (self-hosted service compatible shape). + +Marked with the "model_api" marker and skipped unless DIARIZATION_URL is provided. + +Run with for local self-hosted server: + DIARIZATION_API_KEY=dev-key \ + DIARIZATION_URL=http://localhost:8000 \ + uv run -m pytest -m model_api --no-cov tests/test_model_api_diarization.py +""" + +import os + +import httpx +import pytest + +# Public test audio file hosted on S3 specifically for reflector pytests +TEST_AUDIO_URL = ( + "https://reflector-github-pytest.s3.us-east-1.amazonaws.com/test_mathieu_hello.mp3" +) + + +def get_modal_diarization_url(): + url = os.environ.get("DIARIZATION_URL") + if not url: + pytest.skip( + "DIARIZATION_URL environment variable is required for Model API tests" + ) + return url + + +def get_auth_headers(): + api_key = os.environ.get("DIARIZATION_API_KEY") or os.environ.get( + "REFLECTOR_GPU_APIKEY" + ) + return {"Authorization": f"Bearer {api_key}"} if api_key else {} + + +@pytest.mark.model_api +class TestModelAPIDiarization: + def test_diarize_from_url(self): + url = get_modal_diarization_url() + headers = get_auth_headers() + + with httpx.Client(timeout=60.0) as client: + response = client.post( + f"{url}/diarize", + params={"audio_file_url": TEST_AUDIO_URL, "timestamp": 0.0}, + headers=headers, + ) + + assert response.status_code == 200, f"Request failed: {response.text}" + result = response.json() + + assert "diarization" in result + assert isinstance(result["diarization"], list) + assert len(result["diarization"]) > 0 + + for seg in result["diarization"]: + assert "start" in seg and "end" in seg and "speaker" in seg + assert isinstance(seg["start"], (int, float)) + assert isinstance(seg["end"], (int, float)) + assert seg["start"] <= seg["end"] diff --git a/server/tests/test_gpu_modal_transcript.py b/server/tests/test_model_api_transcript.py similarity index 93% rename from server/tests/test_gpu_modal_transcript.py rename to server/tests/test_model_api_transcript.py index 9b37fbe6..f4a21283 100644 --- a/server/tests/test_gpu_modal_transcript.py +++ b/server/tests/test_model_api_transcript.py @@ -1,21 +1,21 @@ """ -Tests for GPU Modal transcription endpoints. +Tests for transcription Model API endpoints. -These tests are marked with the "gpu-modal" group and will not run by default. -Run them with: pytest -m gpu-modal tests/test_gpu_modal_transcript_parakeet.py +These tests are marked with the "model_api" group and will not run by default. +Run them with: pytest -m model_api tests/test_model_api_transcript.py Required environment variables: -- TRANSCRIPT_URL: URL to the Modal.com endpoint (required) -- TRANSCRIPT_MODAL_API_KEY: API key for authentication (optional) +- TRANSCRIPT_URL: URL to the Model API endpoint (required) +- TRANSCRIPT_API_KEY: API key for authentication (optional) - TRANSCRIPT_MODEL: Model name to use (optional, defaults to nvidia/parakeet-tdt-0.6b-v2) -Example with pytest (override default addopts to run ONLY gpu_modal tests): +Example with pytest (override default addopts to run ONLY model_api tests): TRANSCRIPT_URL=https://monadical-sas--reflector-transcriber-parakeet-web-dev.modal.run \ - TRANSCRIPT_MODAL_API_KEY=your-api-key \ - uv run -m pytest -m gpu_modal --no-cov tests/test_gpu_modal_transcript.py + TRANSCRIPT_API_KEY=your-api-key \ + uv run -m pytest -m model_api --no-cov tests/test_model_api_transcript.py # Or with completely clean options: - uv run -m pytest -m gpu_modal -o addopts="" tests/ + uv run -m pytest -m model_api -o addopts="" tests/ Running Modal locally for testing: modal serve gpu/modal_deployments/reflector_transcriber_parakeet.py @@ -40,14 +40,16 @@ def get_modal_transcript_url(): url = os.environ.get("TRANSCRIPT_URL") if not url: pytest.skip( - "TRANSCRIPT_URL environment variable is required for GPU Modal tests" + "TRANSCRIPT_URL environment variable is required for Model API tests" ) return url def get_auth_headers(): """Get authentication headers if API key is available.""" - api_key = os.environ.get("TRANSCRIPT_MODAL_API_KEY") + api_key = os.environ.get("TRANSCRIPT_API_KEY") or os.environ.get( + "REFLECTOR_GPU_APIKEY" + ) if api_key: return {"Authorization": f"Bearer {api_key}"} return {} @@ -58,8 +60,8 @@ def get_model_name(): return os.environ.get("TRANSCRIPT_MODEL", "nvidia/parakeet-tdt-0.6b-v2") -@pytest.mark.gpu_modal -class TestGPUModalTranscript: +@pytest.mark.model_api +class TestModelAPITranscript: """Test suite for GPU Modal transcription endpoints.""" def test_transcriptions_from_url(self): @@ -272,6 +274,9 @@ class TestGPUModalTranscript: for f in temp_files: Path(f).unlink(missing_ok=True) + @pytest.mark.skipif( + not "parakeet" in get_model_name(), reason="Parakeet only supports English" + ) def test_transcriptions_error_handling(self): """Test error handling for invalid requests.""" url = get_modal_transcript_url() diff --git a/server/tests/test_model_api_translation.py b/server/tests/test_model_api_translation.py new file mode 100644 index 00000000..47e3141f --- /dev/null +++ b/server/tests/test_model_api_translation.py @@ -0,0 +1,56 @@ +""" +Tests for translation Model API endpoint (self-hosted service compatible shape). + +Marked with the "model_api" marker and skipped unless TRANSLATION_URL is provided +or we fallback to TRANSCRIPT_URL base (same host for self-hosted). + +Run locally against self-hosted server: + TRANSLATION_API_KEY=dev-key \ + TRANSLATION_URL=http://localhost:8000 \ + uv run -m pytest -m model_api --no-cov tests/test_model_api_translation.py +""" + +import os + +import httpx +import pytest + + +def get_translation_url(): + url = os.environ.get("TRANSLATION_URL") or os.environ.get("TRANSCRIPT_URL") + if not url: + pytest.skip( + "TRANSLATION_URL or TRANSCRIPT_URL environment variable is required for Model API tests" + ) + return url + + +def get_auth_headers(): + api_key = os.environ.get("TRANSLATION_API_KEY") or os.environ.get( + "REFLECTOR_GPU_APIKEY" + ) + return {"Authorization": f"Bearer {api_key}"} if api_key else {} + + +@pytest.mark.model_api +class TestModelAPITranslation: + def test_translate_text(self): + url = get_translation_url() + headers = get_auth_headers() + + with httpx.Client(timeout=60.0) as client: + response = client.post( + f"{url}/translate", + params={"text": "The meeting will start in five minutes."}, + json={"source_language": "en", "target_language": "fr"}, + headers=headers, + ) + + assert response.status_code == 200, f"Request failed: {response.text}" + data = response.json() + + assert "text" in data and isinstance(data["text"], dict) + assert data["text"].get("en") == "The meeting will start in five minutes." + assert isinstance(data["text"].get("fr", ""), str) + assert len(data["text"]["fr"]) > 0 + assert data["text"]["fr"] == "La réunion commencera dans cinq minutes." diff --git a/server/tests/test_multiple_active_meetings.py b/server/tests/test_multiple_active_meetings.py new file mode 100644 index 00000000..61bce0e0 --- /dev/null +++ b/server/tests/test_multiple_active_meetings.py @@ -0,0 +1,167 @@ +"""Tests for multiple active meetings per room functionality.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.meetings import meetings_controller +from reflector.db.rooms import rooms_controller + + +@pytest.mark.asyncio +async def test_multiple_active_meetings_per_room(): + """Test that multiple active meetings can exist for the same room.""" + # Create a room + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + current_time = datetime.now(timezone.utc) + end_time = current_time + timedelta(hours=2) + + # Create first meeting + meeting1 = await meetings_controller.create( + id="meeting-1", + room_name="test-meeting-1", + room_url="https://whereby.com/test-1", + host_room_url="https://whereby.com/test-1-host", + start_date=current_time, + end_date=end_time, + room=room, + ) + + # Create second meeting for the same room (should succeed now) + meeting2 = await meetings_controller.create( + id="meeting-2", + room_name="test-meeting-2", + room_url="https://whereby.com/test-2", + host_room_url="https://whereby.com/test-2-host", + start_date=current_time, + end_date=end_time, + room=room, + ) + + # Both meetings should be active + active_meetings = await meetings_controller.get_all_active_for_room( + room=room, current_time=current_time + ) + + assert len(active_meetings) == 2 + assert meeting1.id in [m.id for m in active_meetings] + assert meeting2.id in [m.id for m in active_meetings] + + +@pytest.mark.asyncio +async def test_get_active_by_calendar_event(): + """Test getting active meeting by calendar event ID.""" + # Create a room + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + # Create a calendar event + event = CalendarEvent( + room_id=room.id, + ics_uid="test-event-uid", + title="Test Meeting", + start_time=datetime.now(timezone.utc), + end_time=datetime.now(timezone.utc) + timedelta(hours=1), + ) + event = await calendar_events_controller.upsert(event) + + current_time = datetime.now(timezone.utc) + end_time = current_time + timedelta(hours=2) + + # Create meeting linked to calendar event + meeting = await meetings_controller.create( + id="meeting-cal-1", + room_name="test-meeting-cal", + room_url="https://whereby.com/test-cal", + host_room_url="https://whereby.com/test-cal-host", + start_date=current_time, + end_date=end_time, + room=room, + calendar_event_id=event.id, + calendar_metadata={"title": event.title}, + ) + + # Should find the meeting by calendar event + found_meeting = await meetings_controller.get_active_by_calendar_event( + room=room, calendar_event_id=event.id, current_time=current_time + ) + + assert found_meeting is not None + assert found_meeting.id == meeting.id + assert found_meeting.calendar_event_id == event.id + + +@pytest.mark.asyncio +async def test_calendar_meeting_deactivates_after_scheduled_end(): + """Test that unused calendar meetings deactivate after scheduled end time.""" + # Create a room + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + # Create a calendar event that ended 35 minutes ago + event = CalendarEvent( + room_id=room.id, + ics_uid="test-event-unused", + title="Test Meeting Unused", + start_time=datetime.now(timezone.utc) - timedelta(hours=2), + end_time=datetime.now(timezone.utc) - timedelta(minutes=35), + ) + event = await calendar_events_controller.upsert(event) + + current_time = datetime.now(timezone.utc) + + # Create meeting linked to calendar event + meeting = await meetings_controller.create( + id="meeting-unused", + room_name="test-meeting-unused", + room_url="https://whereby.com/test-unused", + host_room_url="https://whereby.com/test-unused-host", + start_date=event.start_time, + end_date=event.end_time, + room=room, + calendar_event_id=event.id, + ) + + # Test the new logic: unused calendar meetings deactivate after scheduled end + # The meeting ended 35 minutes ago and was never used, so it should be deactivated + + # Simulate process_meetings logic for unused calendar meeting past end time + if meeting.calendar_event_id and current_time > meeting.end_date: + # In real code, we'd check has_had_sessions = False here + await meetings_controller.update_meeting(meeting.id, is_active=False) + + updated_meeting = await meetings_controller.get_by_id(meeting.id) + assert updated_meeting.is_active is False # Deactivated after scheduled end diff --git a/server/tests/test_pipeline_main_file.py b/server/tests/test_pipeline_main_file.py index f86dc85d..825c8389 100644 --- a/server/tests/test_pipeline_main_file.py +++ b/server/tests/test_pipeline_main_file.py @@ -127,18 +127,27 @@ async def mock_storage(): from reflector.storage.base import Storage class TestStorage(Storage): - async def _put_file(self, path, data): + async def _put_file(self, path, data, bucket=None): return None - async def _get_file_url(self, path): + async def _get_file_url( + self, + path, + operation: str = "get_object", + expires_in: int = 3600, + bucket=None, + ): return f"http://test-storage/{path}" - async def _get_file(self, path): + async def _get_file(self, path, bucket=None): return b"test_audio_data" - async def _delete_file(self, path): + async def _delete_file(self, path, bucket=None): return None + async def _stream_to_fileobj(self, path, fileobj, bucket=None): + fileobj.write(b"test_audio_data") + storage = TestStorage() # Add mock tracking for verification storage._put_file = AsyncMock(side_effect=storage._put_file) @@ -181,7 +190,7 @@ async def mock_waveform_processor(): async def mock_topic_detector(): """Mock TranscriptTopicDetectorProcessor""" with patch( - "reflector.pipelines.main_file_pipeline.TranscriptTopicDetectorProcessor" + "reflector.pipelines.topic_processing.TranscriptTopicDetectorProcessor" ) as mock_topic_class: mock_topic = AsyncMock() mock_topic.set_pipeline = MagicMock() @@ -218,7 +227,7 @@ async def mock_topic_detector(): async def mock_title_processor(): """Mock TranscriptFinalTitleProcessor""" with patch( - "reflector.pipelines.main_file_pipeline.TranscriptFinalTitleProcessor" + "reflector.pipelines.topic_processing.TranscriptFinalTitleProcessor" ) as mock_title_class: mock_title = AsyncMock() mock_title.set_pipeline = MagicMock() @@ -247,7 +256,7 @@ async def mock_title_processor(): async def mock_summary_processor(): """Mock TranscriptFinalSummaryProcessor""" with patch( - "reflector.pipelines.main_file_pipeline.TranscriptFinalSummaryProcessor" + "reflector.pipelines.topic_processing.TranscriptFinalSummaryProcessor" ) as mock_summary_class: mock_summary = AsyncMock() mock_summary.set_pipeline = MagicMock() diff --git a/server/tests/test_processors_pipeline.py b/server/tests/test_processors_pipeline.py deleted file mode 100644 index 7ae22a6c..00000000 --- a/server/tests/test_processors_pipeline.py +++ /dev/null @@ -1,61 +0,0 @@ -import pytest - - -@pytest.mark.asyncio -@pytest.mark.parametrize("enable_diarization", [False, True]) -async def test_basic_process( - dummy_transcript, - dummy_llm, - dummy_processors, - enable_diarization, - dummy_diarization, -): - # goal is to start the server, and send rtc audio to it - # validate the events received - from pathlib import Path - - from reflector.settings import settings - from reflector.tools.process import process_audio_file - - # LLM_BACKEND no longer exists in settings - # settings.LLM_BACKEND = "test" - settings.TRANSCRIPT_BACKEND = "whisper" - - # event callback - marks = {} - - async def event_callback(event): - if event.processor not in marks: - marks[event.processor] = 0 - marks[event.processor] += 1 - - # invoke the process and capture events - path = Path(__file__).parent / "records" / "test_mathieu_hello.wav" - - if enable_diarization: - # Test with diarization - may fail if pyannote.audio is not installed - try: - await process_audio_file( - path.as_posix(), event_callback, enable_diarization=True - ) - except SystemExit: - pytest.skip("pyannote.audio not installed - skipping diarization test") - else: - # Test without diarization - should always work - await process_audio_file( - path.as_posix(), event_callback, enable_diarization=False - ) - - print(f"Diarization: {enable_diarization}, Marks: {marks}") - - # validate the events - # Each processor should be called for each audio segment processed - # The final processors (Topic, Title, Summary) should be called once at the end - assert marks["TranscriptLinerProcessor"] > 0 - assert marks["TranscriptTranslatorPassthroughProcessor"] > 0 - assert marks["TranscriptTopicDetectorProcessor"] == 1 - assert marks["TranscriptFinalSummaryProcessor"] == 1 - assert marks["TranscriptFinalTitleProcessor"] == 1 - - if enable_diarization: - assert marks["TestAudioDiarizationProcessor"] == 1 diff --git a/server/tests/test_room_ics.py b/server/tests/test_room_ics.py new file mode 100644 index 00000000..7a3c4d74 --- /dev/null +++ b/server/tests/test_room_ics.py @@ -0,0 +1,225 @@ +""" +Tests for Room model ICS calendar integration fields. +""" + +from datetime import datetime, timezone + +import pytest + +from reflector.db.rooms import rooms_controller + + +@pytest.mark.asyncio +async def test_room_create_with_ics_fields(): + """Test creating a room with ICS calendar fields.""" + room = await rooms_controller.add( + name="test-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.google.com/calendar/ical/test/private-token/basic.ics", + ics_fetch_interval=600, + ics_enabled=True, + ) + + assert room.name == "test-room" + assert ( + room.ics_url + == "https://calendar.google.com/calendar/ical/test/private-token/basic.ics" + ) + assert room.ics_fetch_interval == 600 + assert room.ics_enabled is True + assert room.ics_last_sync is None + assert room.ics_last_etag is None + + +@pytest.mark.asyncio +async def test_room_update_ics_configuration(): + """Test updating room ICS configuration.""" + # Create room without ICS + room = await rooms_controller.add( + name="update-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ) + + assert room.ics_enabled is False + assert room.ics_url is None + + # Update with ICS configuration + await rooms_controller.update( + room, + { + "ics_url": "https://outlook.office365.com/owa/calendar/test/calendar.ics", + "ics_fetch_interval": 300, + "ics_enabled": True, + }, + ) + + assert ( + room.ics_url == "https://outlook.office365.com/owa/calendar/test/calendar.ics" + ) + assert room.ics_fetch_interval == 300 + assert room.ics_enabled is True + + +@pytest.mark.asyncio +async def test_room_ics_sync_metadata(): + """Test updating room ICS sync metadata.""" + room = await rooms_controller.add( + name="sync-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://example.com/calendar.ics", + ics_enabled=True, + ) + + # Update sync metadata + sync_time = datetime.now(timezone.utc) + await rooms_controller.update( + room, + { + "ics_last_sync": sync_time, + "ics_last_etag": "abc123hash", + }, + ) + + assert room.ics_last_sync == sync_time + assert room.ics_last_etag == "abc123hash" + + +@pytest.mark.asyncio +async def test_room_get_with_ics_fields(): + """Test retrieving room with ICS fields.""" + # Create room + created_room = await rooms_controller.add( + name="get-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="webcal://calendar.example.com/feed.ics", + ics_fetch_interval=900, + ics_enabled=True, + ) + + # Get by ID + room = await rooms_controller.get_by_id(created_room.id) + assert room is not None + assert room.ics_url == "webcal://calendar.example.com/feed.ics" + assert room.ics_fetch_interval == 900 + assert room.ics_enabled is True + + # Get by name + room = await rooms_controller.get_by_name("get-test") + assert room is not None + assert room.ics_url == "webcal://calendar.example.com/feed.ics" + assert room.ics_fetch_interval == 900 + assert room.ics_enabled is True + + +@pytest.mark.asyncio +async def test_room_list_with_ics_enabled_filter(): + """Test listing rooms filtered by ICS enabled status.""" + # Create rooms with and without ICS + room1 = await rooms_controller.add( + name="ics-enabled-1", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=True, + ics_url="https://calendar1.example.com/feed.ics", + ) + + room2 = await rooms_controller.add( + name="ics-disabled", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=False, + ) + + room3 = await rooms_controller.add( + name="ics-enabled-2", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + ics_enabled=True, + ics_url="https://calendar2.example.com/feed.ics", + ) + + # Get all rooms + all_rooms = await rooms_controller.get_all() + assert len(all_rooms) == 3 + + # Filter for ICS-enabled rooms (would need to implement this in controller) + ics_rooms = [r for r in all_rooms if r["ics_enabled"]] + assert len(ics_rooms) == 2 + assert all(r["ics_enabled"] for r in ics_rooms) + + +@pytest.mark.asyncio +async def test_room_default_ics_values(): + """Test that ICS fields have correct default values.""" + room = await rooms_controller.add( + name="default-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + # Don't specify ICS fields + ) + + assert room.ics_url is None + assert room.ics_fetch_interval == 300 # Default 5 minutes + assert room.ics_enabled is False + assert room.ics_last_sync is None + assert room.ics_last_etag is None diff --git a/server/tests/test_room_ics_api.py b/server/tests/test_room_ics_api.py new file mode 100644 index 00000000..79512995 --- /dev/null +++ b/server/tests/test_room_ics_api.py @@ -0,0 +1,407 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest +from icalendar import Calendar, Event + +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.rooms import rooms_controller + + +@pytest.fixture +async def authenticated_client(client): + from reflector.app import app + from reflector.auth import current_user, current_user_optional + + app.dependency_overrides[current_user] = lambda: { + "sub": "test-user", + "email": "test@example.com", + } + app.dependency_overrides[current_user_optional] = lambda: { + "sub": "test-user", + "email": "test@example.com", + } + try: + yield client + finally: + del app.dependency_overrides[current_user] + del app.dependency_overrides[current_user_optional] + + +@pytest.mark.asyncio +async def test_create_room_with_ics_fields(authenticated_client): + client = authenticated_client + response = await client.post( + "/rooms", + json={ + "name": "test-ics-room", + "zulip_auto_post": False, + "zulip_stream": "", + "zulip_topic": "", + "is_locked": False, + "room_mode": "normal", + "recording_type": "cloud", + "recording_trigger": "automatic-2nd-participant", + "is_shared": False, + "webhook_url": "", + "webhook_secret": "", + "ics_url": "https://calendar.example.com/test.ics", + "ics_fetch_interval": 600, + "ics_enabled": True, + "platform": "daily", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "test-ics-room" + assert data["ics_url"] == "https://calendar.example.com/test.ics" + assert data["ics_fetch_interval"] == 600 + assert data["ics_enabled"] is True + + +@pytest.mark.asyncio +async def test_update_room_ics_configuration(authenticated_client): + client = authenticated_client + response = await client.post( + "/rooms", + json={ + "name": "update-ics-room", + "zulip_auto_post": False, + "zulip_stream": "", + "zulip_topic": "", + "is_locked": False, + "room_mode": "normal", + "recording_type": "cloud", + "recording_trigger": "automatic-2nd-participant", + "is_shared": False, + "webhook_url": "", + "webhook_secret": "", + "platform": "daily", + }, + ) + assert response.status_code == 200 + room_id = response.json()["id"] + + response = await client.patch( + f"/rooms/{room_id}", + json={ + "ics_url": "https://calendar.google.com/updated.ics", + "ics_fetch_interval": 300, + "ics_enabled": True, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["ics_url"] == "https://calendar.google.com/updated.ics" + assert data["ics_fetch_interval"] == 300 + assert data["ics_enabled"] is True + + +@pytest.mark.asyncio +async def test_trigger_ics_sync(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="sync-api-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/api.ics", + ics_enabled=True, + platform="daily", + ) + + cal = Calendar() + event = Event() + event.add("uid", "api-test-event") + event.add("summary", "API Test Meeting") + from reflector.settings import settings + + event.add("location", f"{settings.UI_BASE_URL}/{room.name}") + now = datetime.now(timezone.utc) + event.add("dtstart", now + timedelta(hours=1)) + event.add("dtend", now + timedelta(hours=2)) + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + + with patch( + "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content + + response = await client.post(f"/rooms/{room.name}/ics/sync") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["events_found"] == 1 + assert data["events_created"] == 1 + + +@pytest.mark.asyncio +async def test_trigger_ics_sync_unauthorized(client): + room = await rooms_controller.add( + name="sync-unauth-room", + user_id="owner-123", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/api.ics", + ics_enabled=True, + platform="daily", + ) + + response = await client.post(f"/rooms/{room.name}/ics/sync") + assert response.status_code == 403 + assert "Only room owner can trigger ICS sync" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_trigger_ics_sync_not_configured(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="sync-not-configured", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_enabled=False, + platform="daily", + ) + + response = await client.post(f"/rooms/{room.name}/ics/sync") + assert response.status_code == 400 + assert "ICS not configured" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_get_ics_status(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="status-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/status.ics", + ics_enabled=True, + ics_fetch_interval=300, + platform="daily", + ) + + now = datetime.now(timezone.utc) + await rooms_controller.update( + room, + {"ics_last_sync": now, "ics_last_etag": "test-etag"}, + ) + + response = await client.get(f"/rooms/{room.name}/ics/status") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "enabled" + assert data["last_etag"] == "test-etag" + assert data["events_count"] == 0 + + +@pytest.mark.asyncio +async def test_get_ics_status_unauthorized(client): + room = await rooms_controller.add( + name="status-unauth", + user_id="owner-456", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url="https://calendar.example.com/status.ics", + ics_enabled=True, + platform="daily", + ) + + response = await client.get(f"/rooms/{room.name}/ics/status") + assert response.status_code == 403 + assert "Only room owner can view ICS status" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_list_room_meetings(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="meetings-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + platform="daily", + ) + + now = datetime.now(timezone.utc) + event1 = CalendarEvent( + room_id=room.id, + ics_uid="meeting-1", + title="Past Meeting", + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + ) + await calendar_events_controller.upsert(event1) + + event2 = CalendarEvent( + room_id=room.id, + ics_uid="meeting-2", + title="Future Meeting", + description="Team sync", + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + attendees=[{"email": "test@example.com"}], + ) + await calendar_events_controller.upsert(event2) + + response = await client.get(f"/rooms/{room.name}/meetings") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "Past Meeting" + assert data[1]["title"] == "Future Meeting" + assert data[1]["description"] == "Team sync" + assert data[1]["attendees"] == [{"email": "test@example.com"}] + + +@pytest.mark.asyncio +async def test_list_room_meetings_non_owner(client): + room = await rooms_controller.add( + name="meetings-privacy", + user_id="owner-789", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + platform="daily", + ) + + event = CalendarEvent( + room_id=room.id, + ics_uid="private-meeting", + title="Meeting Title", + description="Sensitive info", + start_time=datetime.now(timezone.utc) + timedelta(hours=1), + end_time=datetime.now(timezone.utc) + timedelta(hours=2), + attendees=[{"email": "private@example.com"}], + ) + await calendar_events_controller.upsert(event) + + response = await client.get(f"/rooms/{room.name}/meetings") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["title"] == "Meeting Title" + assert data[0]["description"] is None + assert data[0]["attendees"] is None + + +@pytest.mark.asyncio +async def test_list_upcoming_meetings(authenticated_client): + client = authenticated_client + room = await rooms_controller.add( + name="upcoming-room", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + platform="daily", + ) + + now = datetime.now(timezone.utc) + + past_event = CalendarEvent( + room_id=room.id, + ics_uid="past", + title="Past", + start_time=now - timedelta(hours=1), + end_time=now - timedelta(minutes=30), + ) + await calendar_events_controller.upsert(past_event) + + soon_event = CalendarEvent( + room_id=room.id, + ics_uid="soon", + title="Soon", + start_time=now + timedelta(minutes=15), + end_time=now + timedelta(minutes=45), + ) + await calendar_events_controller.upsert(soon_event) + + later_event = CalendarEvent( + room_id=room.id, + ics_uid="later", + title="Later", + start_time=now + timedelta(hours=2), + end_time=now + timedelta(hours=3), + ) + await calendar_events_controller.upsert(later_event) + + response = await client.get(f"/rooms/{room.name}/meetings/upcoming") + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "Soon" + assert data[1]["title"] == "Later" + + response = await client.get( + f"/rooms/{room.name}/meetings/upcoming", params={"minutes_ahead": 180} + ) + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["title"] == "Soon" + assert data[1]["title"] == "Later" + + +@pytest.mark.asyncio +async def test_room_not_found_endpoints(client): + response = await client.post("/rooms/nonexistent/ics/sync") + assert response.status_code == 404 + + response = await client.get("/rooms/nonexistent/ics/status") + assert response.status_code == 404 + + response = await client.get("/rooms/nonexistent/meetings") + assert response.status_code == 404 + + response = await client.get("/rooms/nonexistent/meetings/upcoming") + assert response.status_code == 404 diff --git a/server/tests/test_s3_url_parser.py b/server/tests/test_s3_url_parser.py new file mode 100644 index 00000000..638f7c29 --- /dev/null +++ b/server/tests/test_s3_url_parser.py @@ -0,0 +1,136 @@ +"""Tests for S3 URL parsing functionality in reflector.tools.process""" + +import pytest + +from reflector.tools.process import parse_s3_url + + +class TestParseS3URL: + """Test cases for parse_s3_url function""" + + def test_parse_s3_protocol(self): + """Test parsing s3:// protocol URLs""" + bucket, key = parse_s3_url("s3://my-bucket/path/to/file.webm") + assert bucket == "my-bucket" + assert key == "path/to/file.webm" + + def test_parse_s3_protocol_deep_path(self): + """Test s3:// with deeply nested paths""" + bucket, key = parse_s3_url("s3://bucket-name/very/deep/path/to/audio.mp4") + assert bucket == "bucket-name" + assert key == "very/deep/path/to/audio.mp4" + + def test_parse_https_subdomain_format(self): + """Test parsing https://bucket.s3.amazonaws.com/key format""" + bucket, key = parse_s3_url("https://my-bucket.s3.amazonaws.com/path/file.webm") + assert bucket == "my-bucket" + assert key == "path/file.webm" + + def test_parse_https_regional_subdomain(self): + """Test parsing regional endpoint with subdomain""" + bucket, key = parse_s3_url( + "https://my-bucket.s3.us-west-2.amazonaws.com/path/file.webm" + ) + assert bucket == "my-bucket" + assert key == "path/file.webm" + + def test_parse_https_path_style(self): + """Test parsing https://s3.amazonaws.com/bucket/key format""" + bucket, key = parse_s3_url("https://s3.amazonaws.com/my-bucket/path/file.webm") + assert bucket == "my-bucket" + assert key == "path/file.webm" + + def test_parse_https_regional_path_style(self): + """Test parsing regional endpoint with path style""" + bucket, key = parse_s3_url( + "https://s3.us-east-1.amazonaws.com/my-bucket/path/file.webm" + ) + assert bucket == "my-bucket" + assert key == "path/file.webm" + + def test_parse_url_encoded_keys(self): + """Test parsing URL-encoded keys""" + bucket, key = parse_s3_url( + "s3://my-bucket/path%20with%20spaces/file%2Bname.webm" + ) + assert bucket == "my-bucket" + assert key == "path with spaces/file+name.webm" # Should be decoded + + def test_parse_url_encoded_https(self): + """Test URL-encoded keys with HTTPS format""" + bucket, key = parse_s3_url( + "https://my-bucket.s3.amazonaws.com/file%20with%20spaces.webm" + ) + assert bucket == "my-bucket" + assert key == "file with spaces.webm" + + def test_invalid_url_no_scheme(self): + """Test that URLs without scheme raise ValueError""" + with pytest.raises(ValueError, match="Invalid S3 URL scheme"): + parse_s3_url("my-bucket/path/file.webm") + + def test_invalid_url_wrong_scheme(self): + """Test that non-S3 schemes raise ValueError""" + with pytest.raises(ValueError, match="Invalid S3 URL scheme"): + parse_s3_url("ftp://my-bucket/path/file.webm") + + def test_invalid_s3_missing_bucket(self): + """Test s3:// URL without bucket raises ValueError""" + with pytest.raises(ValueError, match="missing bucket or key"): + parse_s3_url("s3:///path/file.webm") + + def test_invalid_s3_missing_key(self): + """Test s3:// URL without key raises ValueError""" + with pytest.raises(ValueError, match="missing bucket or key"): + parse_s3_url("s3://my-bucket/") + + def test_invalid_s3_empty_key(self): + """Test s3:// URL with empty key raises ValueError""" + with pytest.raises(ValueError, match="missing bucket or key"): + parse_s3_url("s3://my-bucket") + + def test_invalid_https_not_s3(self): + """Test HTTPS URL that's not S3 raises ValueError""" + with pytest.raises(ValueError, match="not recognized as S3 URL"): + parse_s3_url("https://example.com/path/file.webm") + + def test_invalid_https_subdomain_missing_key(self): + """Test HTTPS subdomain format without key raises ValueError""" + with pytest.raises(ValueError, match="missing bucket or key"): + parse_s3_url("https://my-bucket.s3.amazonaws.com/") + + def test_invalid_https_path_style_missing_parts(self): + """Test HTTPS path style with missing bucket/key raises ValueError""" + with pytest.raises(ValueError, match="missing bucket or key"): + parse_s3_url("https://s3.amazonaws.com/") + + def test_bucket_with_dots(self): + """Test parsing bucket names with dots""" + bucket, key = parse_s3_url("s3://my.bucket.name/path/file.webm") + assert bucket == "my.bucket.name" + assert key == "path/file.webm" + + def test_bucket_with_hyphens(self): + """Test parsing bucket names with hyphens""" + bucket, key = parse_s3_url("s3://my-bucket-name-123/path/file.webm") + assert bucket == "my-bucket-name-123" + assert key == "path/file.webm" + + def test_key_with_special_chars(self): + """Test keys with various special characters""" + # Note: # is treated as URL fragment separator, not part of key + bucket, key = parse_s3_url("s3://bucket/2024-01-01_12:00:00/file.webm") + assert bucket == "bucket" + assert key == "2024-01-01_12:00:00/file.webm" + + def test_fragment_handling(self): + """Test that URL fragments are properly ignored""" + bucket, key = parse_s3_url("s3://bucket/path/to/file.webm#fragment123") + assert bucket == "bucket" + assert key == "path/to/file.webm" # Fragment not included + + def test_http_scheme_s3_url(self): + """Test that HTTP (not HTTPS) S3 URLs are supported""" + bucket, key = parse_s3_url("http://my-bucket.s3.amazonaws.com/path/file.webm") + assert bucket == "my-bucket" + assert key == "path/file.webm" diff --git a/server/tests/test_search.py b/server/tests/test_search.py index 61145bf9..82890080 100644 --- a/server/tests/test_search.py +++ b/server/tests/test_search.py @@ -23,7 +23,7 @@ async def test_search_postgresql_only(): assert results == [] assert total == 0 - params_empty = SearchParameters(query_text="") + params_empty = SearchParameters(query_text=None) results_empty, total_empty = await search_controller.search_transcripts( params_empty ) @@ -34,7 +34,7 @@ async def test_search_postgresql_only(): @pytest.mark.asyncio async def test_search_with_empty_query(): """Test that empty query returns all transcripts.""" - params = SearchParameters(query_text="") + params = SearchParameters(query_text=None) results, total = await search_controller.search_transcripts(params) assert isinstance(results, list) @@ -58,7 +58,7 @@ async def test_empty_transcript_title_only_match(): "id": test_id, "name": "Empty Transcript", "title": "Empty Meeting", - "status": "completed", + "status": "ended", "locked": False, "duration": 0.0, "created_at": datetime.now(timezone.utc), @@ -109,7 +109,7 @@ async def test_search_with_long_summary(): "id": test_id, "name": "Test Long Summary", "title": "Regular Meeting", - "status": "completed", + "status": "ended", "locked": False, "duration": 1800.0, "created_at": datetime.now(timezone.utc), @@ -165,7 +165,7 @@ async def test_postgresql_search_with_data(): "id": test_id, "name": "Test Search Transcript", "title": "Engineering Planning Meeting Q4 2024", - "status": "completed", + "status": "ended", "locked": False, "duration": 1800.0, "created_at": datetime.now(timezone.utc), @@ -221,7 +221,7 @@ We need to implement PostgreSQL tsvector for better performance.""", test_result = next((r for r in results if r.id == test_id), None) if test_result: assert test_result.title == "Engineering Planning Meeting Q4 2024" - assert test_result.status == "completed" + assert test_result.status == "ended" assert test_result.duration == 1800.0 assert 0 <= test_result.rank <= 1, "Rank should be normalized to 0-1" @@ -268,7 +268,7 @@ def mock_db_result(): "title": "Test Transcript", "created_at": datetime(2024, 6, 15, tzinfo=timezone.utc), "duration": 3600.0, - "status": "completed", + "status": "ended", "user_id": "test-user", "room_id": "room1", "source_kind": SourceKind.LIVE, @@ -433,7 +433,7 @@ class TestSearchResultModel: room_id="room-456", source_kind=SourceKind.ROOM, created_at=datetime(2024, 6, 15, tzinfo=timezone.utc), - status="completed", + status="ended", rank=0.85, duration=1800.5, search_snippets=["snippet 1", "snippet 2"], @@ -443,7 +443,7 @@ class TestSearchResultModel: assert result.title == "Test Title" assert result.user_id == "user-123" assert result.room_id == "room-456" - assert result.status == "completed" + assert result.status == "ended" assert result.rank == 0.85 assert result.duration == 1800.5 assert len(result.search_snippets) == 2 @@ -474,7 +474,7 @@ class TestSearchResultModel: id="test-id", source_kind=SourceKind.LIVE, created_at=datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc), - status="completed", + status="ended", rank=0.9, duration=None, search_snippets=[], diff --git a/server/tests/test_search_date_filtering.py b/server/tests/test_search_date_filtering.py new file mode 100644 index 00000000..58fd6446 --- /dev/null +++ b/server/tests/test_search_date_filtering.py @@ -0,0 +1,256 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from reflector.db import get_database +from reflector.db.search import SearchParameters, search_controller +from reflector.db.transcripts import SourceKind, transcripts + + +@pytest.mark.asyncio +class TestDateRangeIntegration: + async def setup_test_transcripts(self): + # Use a test user_id that will match in our search parameters + test_user_id = "test-user-123" + + test_data = [ + { + "id": "test-before-range", + "created_at": datetime(2024, 1, 15, tzinfo=timezone.utc), + "title": "Before Range Transcript", + "user_id": test_user_id, + }, + { + "id": "test-start-boundary", + "created_at": datetime(2024, 6, 1, tzinfo=timezone.utc), + "title": "Start Boundary Transcript", + "user_id": test_user_id, + }, + { + "id": "test-middle-range", + "created_at": datetime(2024, 6, 15, tzinfo=timezone.utc), + "title": "Middle Range Transcript", + "user_id": test_user_id, + }, + { + "id": "test-end-boundary", + "created_at": datetime(2024, 6, 30, 23, 59, 59, tzinfo=timezone.utc), + "title": "End Boundary Transcript", + "user_id": test_user_id, + }, + { + "id": "test-after-range", + "created_at": datetime(2024, 12, 31, tzinfo=timezone.utc), + "title": "After Range Transcript", + "user_id": test_user_id, + }, + ] + + for data in test_data: + full_data = { + "id": data["id"], + "name": data["id"], + "status": "ended", + "locked": False, + "duration": 60.0, + "created_at": data["created_at"], + "title": data["title"], + "short_summary": "Test summary", + "long_summary": "Test long summary", + "share_mode": "public", + "source_kind": SourceKind.FILE, + "audio_deleted": False, + "reviewed": False, + "user_id": data["user_id"], + } + + await get_database().execute(transcripts.insert().values(**full_data)) + + return test_data + + async def cleanup_test_transcripts(self, test_data): + """Clean up test transcripts.""" + for data in test_data: + await get_database().execute( + transcripts.delete().where(transcripts.c.id == data["id"]) + ) + + @pytest.mark.asyncio + async def test_filter_with_from_datetime_only(self): + """Test filtering with only from_datetime parameter.""" + test_data = await self.setup_test_transcripts() + test_user_id = "test-user-123" + + try: + params = SearchParameters( + query_text=None, + from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc), + to_datetime=None, + user_id=test_user_id, + ) + + results, total = await search_controller.search_transcripts(params) + + # Should include: start_boundary, middle, end_boundary, after + result_ids = [r.id for r in results] + assert "test-before-range" not in result_ids + assert "test-start-boundary" in result_ids + assert "test-middle-range" in result_ids + assert "test-end-boundary" in result_ids + assert "test-after-range" in result_ids + + finally: + await self.cleanup_test_transcripts(test_data) + + @pytest.mark.asyncio + async def test_filter_with_to_datetime_only(self): + """Test filtering with only to_datetime parameter.""" + test_data = await self.setup_test_transcripts() + test_user_id = "test-user-123" + + try: + params = SearchParameters( + query_text=None, + from_datetime=None, + to_datetime=datetime(2024, 6, 30, tzinfo=timezone.utc), + user_id=test_user_id, + ) + + results, total = await search_controller.search_transcripts(params) + + result_ids = [r.id for r in results] + assert "test-before-range" in result_ids + assert "test-start-boundary" in result_ids + assert "test-middle-range" in result_ids + assert "test-end-boundary" not in result_ids + assert "test-after-range" not in result_ids + + finally: + await self.cleanup_test_transcripts(test_data) + + @pytest.mark.asyncio + async def test_filter_with_both_datetimes(self): + test_data = await self.setup_test_transcripts() + test_user_id = "test-user-123" + + try: + params = SearchParameters( + query_text=None, + from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc), + to_datetime=datetime( + 2024, 7, 1, tzinfo=timezone.utc + ), # Inclusive of 6/30 + user_id=test_user_id, + ) + + results, total = await search_controller.search_transcripts(params) + + result_ids = [r.id for r in results] + assert "test-before-range" not in result_ids + assert "test-start-boundary" in result_ids + assert "test-middle-range" in result_ids + assert "test-end-boundary" in result_ids + assert "test-after-range" not in result_ids + + finally: + await self.cleanup_test_transcripts(test_data) + + @pytest.mark.asyncio + async def test_date_filter_with_room_and_source_kind(self): + test_data = await self.setup_test_transcripts() + test_user_id = "test-user-123" + + try: + params = SearchParameters( + query_text=None, + from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc), + to_datetime=datetime(2024, 7, 1, tzinfo=timezone.utc), + source_kind=SourceKind.FILE, + room_id=None, + user_id=test_user_id, + ) + + results, total = await search_controller.search_transcripts(params) + + for result in results: + assert result.source_kind == SourceKind.FILE + assert result.created_at >= datetime(2024, 6, 1, tzinfo=timezone.utc) + assert result.created_at <= datetime(2024, 7, 1, tzinfo=timezone.utc) + + finally: + await self.cleanup_test_transcripts(test_data) + + @pytest.mark.asyncio + async def test_empty_results_for_future_dates(self): + test_data = await self.setup_test_transcripts() + test_user_id = "test-user-123" + + try: + params = SearchParameters( + query_text=None, + from_datetime=datetime(2099, 1, 1, tzinfo=timezone.utc), + to_datetime=datetime(2099, 12, 31, tzinfo=timezone.utc), + user_id=test_user_id, + ) + + results, total = await search_controller.search_transcripts(params) + + assert results == [] + assert total == 0 + + finally: + await self.cleanup_test_transcripts(test_data) + + @pytest.mark.asyncio + async def test_date_only_input_handling(self): + test_data = await self.setup_test_transcripts() + test_user_id = "test-user-123" + + try: + # Pydantic will parse date-only strings to datetime at midnight + from_dt = datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc) + to_dt = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc) + + params = SearchParameters( + query_text=None, + from_datetime=from_dt, + to_datetime=to_dt, + user_id=test_user_id, + ) + + results, total = await search_controller.search_transcripts(params) + + result_ids = [r.id for r in results] + assert "test-middle-range" in result_ids + assert "test-before-range" not in result_ids + assert "test-after-range" not in result_ids + + finally: + await self.cleanup_test_transcripts(test_data) + + +class TestDateValidationEdgeCases: + """Edge case tests for datetime validation.""" + + def test_timezone_aware_comparison(self): + """Test that timezone-aware comparisons work correctly.""" + # PST time (UTC-8) + pst = timezone(timedelta(hours=-8)) + pst_dt = datetime(2024, 6, 15, 8, 0, 0, tzinfo=pst) + + # UTC time equivalent (8AM PST = 4PM UTC) + utc_dt = datetime(2024, 6, 15, 16, 0, 0, tzinfo=timezone.utc) + + assert pst_dt == utc_dt + + def test_mixed_timezone_input(self): + """Test handling mixed timezone inputs.""" + pst = timezone(timedelta(hours=-8)) + ist = timezone(timedelta(hours=5, minutes=30)) + + from_date = datetime(2024, 6, 15, 0, 0, 0, tzinfo=pst) # PST midnight + to_date = datetime(2024, 6, 15, 23, 59, 59, tzinfo=ist) # IST end of day + + assert from_date.tzinfo is not None + assert to_date.tzinfo is not None + assert from_date < to_date diff --git a/server/tests/test_search_long_summary.py b/server/tests/test_search_long_summary.py index 8857778b..3f911a99 100644 --- a/server/tests/test_search_long_summary.py +++ b/server/tests/test_search_long_summary.py @@ -25,7 +25,7 @@ async def test_long_summary_snippet_prioritization(): "id": test_id, "name": "Test Snippet Priority", "title": "Meeting About Projects", - "status": "completed", + "status": "ended", "locked": False, "duration": 1800.0, "created_at": datetime.now(timezone.utc), @@ -106,7 +106,7 @@ async def test_long_summary_only_search(): "id": test_id, "name": "Test Long Only", "title": "Standard Meeting", - "status": "completed", + "status": "ended", "locked": False, "duration": 1800.0, "created_at": datetime.now(timezone.utc), diff --git a/server/tests/test_search_snippets.py b/server/tests/test_search_snippets.py index 72267a1b..f9abd03c 100644 --- a/server/tests/test_search_snippets.py +++ b/server/tests/test_search_snippets.py @@ -1,5 +1,7 @@ """Unit tests for search snippet generation.""" +import pytest + from reflector.db.search import ( SnippetCandidate, SnippetGenerator, @@ -512,11 +514,9 @@ data visualization and data storage""" ) assert webvtt_count == 3 - snippets_empty, count_empty = SnippetGenerator.combine_sources( - None, None, "data", max_total=3 - ) - assert snippets_empty == [] - assert count_empty == 0 + # combine_sources requires at least one source to be present + with pytest.raises(AssertionError, match="At least one source must be present"): + SnippetGenerator.combine_sources(None, None, "data", max_total=3) def test_edge_cases(self): """Test edge cases for the pure functions.""" diff --git a/server/tests/test_security_permissions.py b/server/tests/test_security_permissions.py new file mode 100644 index 00000000..ef871152 --- /dev/null +++ b/server/tests/test_security_permissions.py @@ -0,0 +1,384 @@ +import asyncio +import shutil +import threading +import time +from pathlib import Path + +import pytest +from httpx_ws import aconnect_ws +from uvicorn import Config, Server + +from reflector import zulip as zulip_module +from reflector.app import app +from reflector.db import get_database +from reflector.db.meetings import meetings_controller +from reflector.db.rooms import Room, rooms_controller +from reflector.db.transcripts import ( + SourceKind, + TranscriptTopic, + transcripts_controller, +) +from reflector.processors.types import Word +from reflector.settings import settings +from reflector.views.transcripts import create_access_token + + +@pytest.mark.asyncio +async def test_anonymous_cannot_delete_transcript_in_shared_room(client): + # Create a shared room with a fake owner id so meeting has a room_id + room = await rooms_controller.add( + name="shared-room-test", + user_id="owner-1", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=True, + webhook_url="", + webhook_secret="", + ) + + # Create a meeting for that room (so transcript.meeting_id links to the shared room) + meeting = await meetings_controller.create( + id="meeting-sec-test", + room_name="room-sec-test", + room_url="room-url", + host_room_url="host-url", + start_date=Room.model_fields["created_at"].default_factory(), + end_date=Room.model_fields["created_at"].default_factory(), + room=room, + ) + + # Create a transcript owned by someone else and link it to meeting + t = await transcripts_controller.add( + name="to-delete", + source_kind=SourceKind.LIVE, + user_id="owner-2", + meeting_id=meeting.id, + room_id=room.id, + share_mode="private", + ) + + # Anonymous DELETE should be rejected + del_resp = await client.delete(f"/transcripts/{t.id}") + assert del_resp.status_code == 401, del_resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_mutate_participants_on_public_transcript(client): + # Create a public transcript with no owner + t = await transcripts_controller.add( + name="public-transcript", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + # Anonymous POST participant must be rejected + resp = await client.post( + f"/transcripts/{t.id}/participants", + json={"name": "AnonUser", "speaker": 0}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_update_and_delete_room(client): + # Create room as owner id "owner-3" via controller + room = await rooms_controller.add( + name="room-anon-update-delete", + user_id="owner-3", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="", + webhook_secret="", + ) + + # Anonymous PATCH via API (no auth) + resp = await client.patch( + f"/rooms/{room.id}", + json={ + "name": "room-anon-updated", + "zulip_auto_post": False, + "zulip_stream": "", + "zulip_topic": "", + "is_locked": False, + "room_mode": "normal", + "recording_type": "cloud", + "recording_trigger": "automatic-2nd-participant", + "is_shared": False, + "webhook_url": "", + "webhook_secret": "", + }, + ) + # Expect authentication required + assert resp.status_code == 401, resp.text + + # Anonymous DELETE via API + del_resp = await client.delete(f"/rooms/{room.id}") + assert del_resp.status_code == 401, del_resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_post_transcript_to_zulip(client, monkeypatch): + # Create a public transcript with some content + t = await transcripts_controller.add( + name="zulip-public", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + # Mock send/update calls + def _fake_send_message_to_zulip(stream, topic, content): + return {"id": 12345} + + async def _fake_update_message(message_id, stream, topic, content): + return {"result": "success"} + + monkeypatch.setattr( + zulip_module, "send_message_to_zulip", _fake_send_message_to_zulip + ) + monkeypatch.setattr(zulip_module, "update_zulip_message", _fake_update_message) + + # Anonymous POST to Zulip endpoint + resp = await client.post( + f"/transcripts/{t.id}/zulip", + params={"stream": "general", "topic": "Updates", "include_topics": False}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_assign_speaker_on_public_transcript(client): + # Create public transcript + t = await transcripts_controller.add( + name="public-assign", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + # Add a topic with words to be reassigned + topic = TranscriptTopic( + title="T1", + summary="S1", + timestamp=0.0, + transcript="Hello", + words=[Word(start=0.0, end=1.0, text="Hello", speaker=0)], + ) + transcript = await transcripts_controller.get_by_id(t.id) + await transcripts_controller.upsert_topic(transcript, topic) + + # Anonymous assign speaker over time range covering the word + resp = await client.patch( + f"/transcripts/{t.id}/speaker/assign", + json={ + "speaker": 1, + "timestamp_from": 0.0, + "timestamp_to": 1.0, + }, + ) + assert resp.status_code == 401, resp.text + + +# Minimal server fixture for websocket tests +@pytest.fixture +def appserver_ws_simple(setup_database): + host = "127.0.0.1" + port = 1256 + server_started = threading.Event() + server_exception = None + server_instance = None + + def run_server(): + nonlocal server_exception, server_instance + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + config = Config(app=app, host=host, port=port, loop=loop) + server_instance = Server(config) + + async def start_server(): + database = get_database() + await database.connect() + try: + await server_instance.serve() + finally: + await database.disconnect() + + server_started.set() + loop.run_until_complete(start_server()) + except Exception as e: + server_exception = e + server_started.set() + finally: + loop.close() + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + server_started.wait(timeout=30) + if server_exception: + raise server_exception + + time.sleep(0.5) + + yield host, port + + if server_instance: + server_instance.should_exit = True + server_thread.join(timeout=30) + + +@pytest.mark.asyncio +async def test_websocket_denies_anonymous_on_private_transcript(appserver_ws_simple): + host, port = appserver_ws_simple + + # Create a private transcript owned by someone + t = await transcripts_controller.add( + name="private-ws", + source_kind=SourceKind.LIVE, + user_id="owner-x", + share_mode="private", + ) + + base_url = f"http://{host}:{port}/v1" + # Anonymous connect should be denied + with pytest.raises(Exception): + async with aconnect_ws(f"{base_url}/transcripts/{t.id}/events") as ws: + await ws.close() + + +@pytest.mark.asyncio +async def test_anonymous_cannot_update_public_transcript(client): + t = await transcripts_controller.add( + name="update-me", + source_kind=SourceKind.LIVE, + user_id=None, + share_mode="public", + ) + + resp = await client.patch( + f"/transcripts/{t.id}", + json={"title": "New Title From Anonymous"}, + ) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_get_nonshared_room_by_id(client): + room = await rooms_controller.add( + name="private-room-exposed", + user_id="owner-z", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="", + webhook_secret="", + ) + + resp = await client.get(f"/rooms/{room.id}") + assert resp.status_code == 403, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_call_rooms_webhook_test(client): + room = await rooms_controller.add( + name="room-webhook-test", + user_id="owner-y", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + webhook_url="http://localhost.invalid/webhook", + webhook_secret="secret", + ) + + # Anonymous caller + resp = await client.post(f"/rooms/{room.id}/webhook/test") + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_anonymous_cannot_create_room(client): + payload = { + "name": "room-create-auth-required", + "zulip_auto_post": False, + "zulip_stream": "", + "zulip_topic": "", + "is_locked": False, + "room_mode": "normal", + "recording_type": "cloud", + "recording_trigger": "automatic-2nd-participant", + "is_shared": False, + "webhook_url": "", + "webhook_secret": "", + } + resp = await client.post("/rooms", json=payload) + assert resp.status_code == 401, resp.text + + +@pytest.mark.asyncio +async def test_list_search_401_when_public_mode_false(client, monkeypatch): + monkeypatch.setattr(settings, "PUBLIC_MODE", False) + + resp = await client.get("/transcripts") + assert resp.status_code == 401 + + resp = await client.get("/transcripts/search", params={"q": "hello"}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_audio_mp3_requires_token_for_owned_transcript( + client, tmpdir, monkeypatch +): + # Use temp data dir + monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix()) + + # Create owner transcript and attach a local mp3 + t = await transcripts_controller.add( + name="owned-audio", + source_kind=SourceKind.LIVE, + user_id="owner-a", + share_mode="private", + ) + + tr = await transcripts_controller.get_by_id(t.id) + await transcripts_controller.update(tr, {"status": "ended"}) + + # copy fixture audio to transcript path + audio_path = Path(__file__).parent / "records" / "test_mathieu_hello.mp3" + tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(audio_path, tr.audio_mp3_filename) + + # Anonymous GET without token should be 403 or 404 depending on access; we call mp3 + resp = await client.get(f"/transcripts/{t.id}/audio/mp3") + assert resp.status_code == 403 + + # With token should succeed + token = create_access_token( + {"sub": tr.user_id}, expires_delta=__import__("datetime").timedelta(minutes=15) + ) + resp2 = await client.get(f"/transcripts/{t.id}/audio/mp3", params={"token": token}) + assert resp2.status_code == 200 diff --git a/server/tests/test_storage.py b/server/tests/test_storage.py new file mode 100644 index 00000000..ccfc3dbd --- /dev/null +++ b/server/tests/test_storage.py @@ -0,0 +1,321 @@ +"""Tests for storage abstraction layer.""" + +import io +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from botocore.exceptions import ClientError + +from reflector.storage.base import StoragePermissionError +from reflector.storage.storage_aws import AwsStorage + + +@pytest.mark.asyncio +async def test_aws_storage_stream_to_fileobj(): + """Test that AWS storage can stream directly to a file object without loading into memory.""" + # Setup + storage = AwsStorage( + aws_bucket_name="test-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + # Mock download_fileobj to write data + async def mock_download(Bucket, Key, Fileobj, **kwargs): + Fileobj.write(b"chunk1chunk2") + + mock_client = AsyncMock() + mock_client.download_fileobj = AsyncMock(side_effect=mock_download) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + # Patch the session client + with patch.object(storage.session, "client", return_value=mock_client): + # Create a file-like object to stream to + output = io.BytesIO() + + # Act - stream to file object + await storage.stream_to_fileobj("test-file.mp4", output, bucket="test-bucket") + + # Assert + mock_client.download_fileobj.assert_called_once_with( + Bucket="test-bucket", Key="test-file.mp4", Fileobj=output + ) + + # Check that data was written to output + output.seek(0) + assert output.read() == b"chunk1chunk2" + + +@pytest.mark.asyncio +async def test_aws_storage_stream_to_fileobj_with_folder(): + """Test streaming with folder prefix in bucket name.""" + storage = AwsStorage( + aws_bucket_name="test-bucket/recordings", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + async def mock_download(Bucket, Key, Fileobj, **kwargs): + Fileobj.write(b"data") + + mock_client = AsyncMock() + mock_client.download_fileobj = AsyncMock(side_effect=mock_download) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(storage.session, "client", return_value=mock_client): + output = io.BytesIO() + await storage.stream_to_fileobj("file.mp4", output, bucket="other-bucket") + + # Should use folder prefix from instance config + mock_client.download_fileobj.assert_called_once_with( + Bucket="other-bucket", Key="recordings/file.mp4", Fileobj=output + ) + + +@pytest.mark.asyncio +async def test_storage_base_class_stream_to_fileobj(): + """Test that base Storage class has stream_to_fileobj method.""" + from reflector.storage.base import Storage + + # Verify method exists in base class + assert hasattr(Storage, "stream_to_fileobj") + + # Create a mock storage instance + storage = MagicMock(spec=Storage) + storage.stream_to_fileobj = AsyncMock() + + # Should be callable + await storage.stream_to_fileobj("file.mp4", io.BytesIO()) + storage.stream_to_fileobj.assert_called_once() + + +@pytest.mark.asyncio +async def test_aws_storage_stream_uses_download_fileobj(): + """Test that download_fileobj is called correctly.""" + storage = AwsStorage( + aws_bucket_name="test-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + async def mock_download(Bucket, Key, Fileobj, **kwargs): + Fileobj.write(b"data") + + mock_client = AsyncMock() + mock_client.download_fileobj = AsyncMock(side_effect=mock_download) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(storage.session, "client", return_value=mock_client): + output = io.BytesIO() + await storage.stream_to_fileobj("test.mp4", output) + + # Verify download_fileobj was called with correct parameters + mock_client.download_fileobj.assert_called_once_with( + Bucket="test-bucket", Key="test.mp4", Fileobj=output + ) + + +@pytest.mark.asyncio +async def test_aws_storage_handles_access_denied_error(): + """Test that AccessDenied errors are caught and wrapped in StoragePermissionError.""" + storage = AwsStorage( + aws_bucket_name="test-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + # Mock ClientError with AccessDenied + error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}} + mock_client = AsyncMock() + mock_client.put_object = AsyncMock( + side_effect=ClientError(error_response, "PutObject") + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(storage.session, "client", return_value=mock_client): + with pytest.raises(StoragePermissionError) as exc_info: + await storage.put_file("test.txt", b"data") + + # Verify error message contains expected information + error_msg = str(exc_info.value) + assert "AccessDenied" in error_msg + assert "default bucket 'test-bucket'" in error_msg + assert "S3 upload failed" in error_msg + + +@pytest.mark.asyncio +async def test_aws_storage_handles_no_such_bucket_error(): + """Test that NoSuchBucket errors are caught and wrapped in StoragePermissionError.""" + storage = AwsStorage( + aws_bucket_name="test-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + # Mock ClientError with NoSuchBucket + error_response = { + "Error": { + "Code": "NoSuchBucket", + "Message": "The specified bucket does not exist", + } + } + mock_client = AsyncMock() + mock_client.delete_object = AsyncMock( + side_effect=ClientError(error_response, "DeleteObject") + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(storage.session, "client", return_value=mock_client): + with pytest.raises(StoragePermissionError) as exc_info: + await storage.delete_file("test.txt") + + # Verify error message contains expected information + error_msg = str(exc_info.value) + assert "NoSuchBucket" in error_msg + assert "default bucket 'test-bucket'" in error_msg + assert "S3 delete failed" in error_msg + + +@pytest.mark.asyncio +async def test_aws_storage_error_message_with_bucket_override(): + """Test that error messages correctly show overridden bucket.""" + storage = AwsStorage( + aws_bucket_name="default-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + # Mock ClientError with AccessDenied + error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}} + mock_client = AsyncMock() + mock_client.get_object = AsyncMock( + side_effect=ClientError(error_response, "GetObject") + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(storage.session, "client", return_value=mock_client): + with pytest.raises(StoragePermissionError) as exc_info: + await storage.get_file("test.txt", bucket="override-bucket") + + # Verify error message shows overridden bucket, not default + error_msg = str(exc_info.value) + assert "overridden bucket 'override-bucket'" in error_msg + assert "default-bucket" not in error_msg + assert "S3 download failed" in error_msg + + +@pytest.mark.asyncio +async def test_aws_storage_reraises_non_handled_errors(): + """Test that non-AccessDenied/NoSuchBucket errors are re-raised as-is.""" + storage = AwsStorage( + aws_bucket_name="test-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + # Mock ClientError with different error code + error_response = { + "Error": {"Code": "InternalError", "Message": "Internal Server Error"} + } + mock_client = AsyncMock() + mock_client.put_object = AsyncMock( + side_effect=ClientError(error_response, "PutObject") + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(storage.session, "client", return_value=mock_client): + # Should raise ClientError, not StoragePermissionError + with pytest.raises(ClientError) as exc_info: + await storage.put_file("test.txt", b"data") + + # Verify it's the original ClientError + assert exc_info.value.response["Error"]["Code"] == "InternalError" + + +@pytest.mark.asyncio +async def test_aws_storage_presign_url_handles_errors(): + """Test that presigned URL generation handles permission errors.""" + storage = AwsStorage( + aws_bucket_name="test-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + # Mock ClientError with AccessDenied during presign operation + error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}} + mock_client = AsyncMock() + mock_client.generate_presigned_url = AsyncMock( + side_effect=ClientError(error_response, "GeneratePresignedUrl") + ) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(storage.session, "client", return_value=mock_client): + with pytest.raises(StoragePermissionError) as exc_info: + await storage.get_file_url("test.txt") + + # Verify error message + error_msg = str(exc_info.value) + assert "S3 presign failed" in error_msg + assert "AccessDenied" in error_msg + + +@pytest.mark.asyncio +async def test_aws_storage_list_objects_handles_errors(): + """Test that list_objects handles permission errors.""" + storage = AwsStorage( + aws_bucket_name="test-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + ) + + # Mock ClientError during list operation + error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}} + mock_paginator = MagicMock() + + async def mock_paginate(*args, **kwargs): + raise ClientError(error_response, "ListObjectsV2") + yield # Make it an async generator + + mock_paginator.paginate = mock_paginate + + mock_client = AsyncMock() + mock_client.get_paginator = MagicMock(return_value=mock_paginator) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch.object(storage.session, "client", return_value=mock_client): + with pytest.raises(StoragePermissionError) as exc_info: + await storage.list_objects(prefix="test/") + + error_msg = str(exc_info.value) + assert "S3 list_objects failed" in error_msg + assert "AccessDenied" in error_msg + + +def test_aws_storage_constructor_rejects_mixed_auth(): + """Test that constructor rejects both role_arn and access keys.""" + with pytest.raises(ValueError, match="cannot use both.*role_arn.*access keys"): + AwsStorage( + aws_bucket_name="test-bucket", + aws_region="us-east-1", + aws_access_key_id="test-key", + aws_secret_access_key="test-secret", + aws_role_arn="arn:aws:iam::123456789012:role/test-role", + ) diff --git a/server/tests/test_transcripts.py b/server/tests/test_transcripts.py index 8ce0bd36..2c6acc77 100644 --- a/server/tests/test_transcripts.py +++ b/server/tests/test_transcripts.py @@ -1,5 +1,3 @@ -from contextlib import asynccontextmanager - import pytest @@ -19,7 +17,7 @@ async def test_transcript_create(client): @pytest.mark.asyncio -async def test_transcript_get_update_name(client): +async def test_transcript_get_update_name(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["name"] == "test" @@ -40,7 +38,7 @@ async def test_transcript_get_update_name(client): @pytest.mark.asyncio -async def test_transcript_get_update_locked(client): +async def test_transcript_get_update_locked(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["locked"] is False @@ -61,7 +59,7 @@ async def test_transcript_get_update_locked(client): @pytest.mark.asyncio -async def test_transcript_get_update_summary(client): +async def test_transcript_get_update_summary(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["long_summary"] is None @@ -89,7 +87,7 @@ async def test_transcript_get_update_summary(client): @pytest.mark.asyncio -async def test_transcript_get_update_title(client): +async def test_transcript_get_update_title(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["title"] is None @@ -127,56 +125,6 @@ async def test_transcripts_list_anonymous(client): settings.PUBLIC_MODE = False -@asynccontextmanager -async def authenticated_client_ctx(): - from reflector.app import app - from reflector.auth import current_user, current_user_optional - - app.dependency_overrides[current_user] = lambda: { - "sub": "randomuserid", - "email": "test@mail.com", - } - app.dependency_overrides[current_user_optional] = lambda: { - "sub": "randomuserid", - "email": "test@mail.com", - } - yield - del app.dependency_overrides[current_user] - del app.dependency_overrides[current_user_optional] - - -@asynccontextmanager -async def authenticated_client2_ctx(): - from reflector.app import app - from reflector.auth import current_user, current_user_optional - - app.dependency_overrides[current_user] = lambda: { - "sub": "randomuserid2", - "email": "test@mail.com", - } - app.dependency_overrides[current_user_optional] = lambda: { - "sub": "randomuserid2", - "email": "test@mail.com", - } - yield - del app.dependency_overrides[current_user] - del app.dependency_overrides[current_user_optional] - - -@pytest.fixture -@pytest.mark.asyncio -async def authenticated_client(): - async with authenticated_client_ctx(): - yield - - -@pytest.fixture -@pytest.mark.asyncio -async def authenticated_client2(): - async with authenticated_client2_ctx(): - yield - - @pytest.mark.asyncio async def test_transcripts_list_authenticated(authenticated_client, client): # XXX this test is a bit fragile, as it depends on the storage which @@ -199,7 +147,7 @@ async def test_transcripts_list_authenticated(authenticated_client, client): @pytest.mark.asyncio -async def test_transcript_delete(client): +async def test_transcript_delete(authenticated_client, client): response = await client.post("/transcripts", json={"name": "testdel1"}) assert response.status_code == 200 assert response.json()["name"] == "testdel1" @@ -214,7 +162,7 @@ async def test_transcript_delete(client): @pytest.mark.asyncio -async def test_transcript_mark_reviewed(client): +async def test_transcript_mark_reviewed(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["name"] == "test" diff --git a/server/tests/test_transcripts_audio_download.py b/server/tests/test_transcripts_audio_download.py index 81b74def..b7dcfca9 100644 --- a/server/tests/test_transcripts_audio_download.py +++ b/server/tests/test_transcripts_audio_download.py @@ -19,7 +19,7 @@ async def fake_transcript(tmpdir, client): transcript = await transcripts_controller.get_by_id(tid) assert transcript is not None - await transcripts_controller.update(transcript, {"status": "finished"}) + await transcripts_controller.update(transcript, {"status": "ended"}) # manually copy a file at the expected location audio_filename = transcript.audio_mp3_filename @@ -111,7 +111,9 @@ async def test_transcript_audio_download_range_with_seek( @pytest.mark.asyncio -async def test_transcript_delete_with_audio(fake_transcript, client): +async def test_transcript_delete_with_audio( + authenticated_client, fake_transcript, client +): response = await client.delete(f"/transcripts/{fake_transcript.id}") assert response.status_code == 200 assert response.json()["status"] == "ok" diff --git a/server/tests/test_transcripts_participants.py b/server/tests/test_transcripts_participants.py index 076f750e..24ec6a90 100644 --- a/server/tests/test_transcripts_participants.py +++ b/server/tests/test_transcripts_participants.py @@ -2,7 +2,7 @@ import pytest @pytest.mark.asyncio -async def test_transcript_participants(client): +async def test_transcript_participants(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["participants"] == [] @@ -39,7 +39,7 @@ async def test_transcript_participants(client): @pytest.mark.asyncio -async def test_transcript_participants_same_speaker(client): +async def test_transcript_participants_same_speaker(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["participants"] == [] @@ -62,7 +62,7 @@ async def test_transcript_participants_same_speaker(client): @pytest.mark.asyncio -async def test_transcript_participants_update_name(client): +async def test_transcript_participants_update_name(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["participants"] == [] @@ -100,7 +100,7 @@ async def test_transcript_participants_update_name(client): @pytest.mark.asyncio -async def test_transcript_participants_update_speaker(client): +async def test_transcript_participants_update_speaker(authenticated_client, client): response = await client.post("/transcripts", json={"name": "test"}) assert response.status_code == 200 assert response.json()["participants"] == [] diff --git a/server/tests/test_transcripts_process.py b/server/tests/test_transcripts_process.py index 3551d718..3a0614c1 100644 --- a/server/tests/test_transcripts_process.py +++ b/server/tests/test_transcripts_process.py @@ -1,5 +1,6 @@ import asyncio import time +from unittest.mock import patch import pytest from httpx import ASGITransport, AsyncClient @@ -29,10 +30,10 @@ async def client(app_lifespan): @pytest.mark.asyncio async def test_transcript_process( tmpdir, - whisper_transcript, dummy_llm, dummy_processors, - dummy_diarization, + dummy_file_transcript, + dummy_file_diarization, dummy_storage, client, ): @@ -56,8 +57,8 @@ async def test_transcript_process( assert response.status_code == 200 assert response.json()["status"] == "ok" - # wait for processing to finish (max 10 minutes) - timeout_seconds = 600 # 10 minutes + # wait for processing to finish (max 1 minute) + timeout_seconds = 60 start_time = time.monotonic() while (time.monotonic() - start_time) < timeout_seconds: # fetch the transcript and check if it is ended @@ -75,9 +76,10 @@ async def test_transcript_process( ) assert response.status_code == 200 assert response.json()["status"] == "ok" + await asyncio.sleep(2) - # wait for processing to finish (max 10 minutes) - timeout_seconds = 600 # 10 minutes + # wait for processing to finish (max 1 minute) + timeout_seconds = 60 start_time = time.monotonic() while (time.monotonic() - start_time) < timeout_seconds: # fetch the transcript and check if it is ended @@ -99,4 +101,114 @@ async def test_transcript_process( response = await client.get(f"/transcripts/{tid}/topics") assert response.status_code == 200 assert len(response.json()) == 1 - assert "want to share" in response.json()[0]["transcript"] + assert "Hello world. How are you today?" in response.json()[0]["transcript"] + + +@pytest.mark.usefixtures("setup_database") +@pytest.mark.asyncio +async def test_whereby_recording_uses_file_pipeline(client): + """Test that Whereby recordings (bucket_name but no track_keys) use file pipeline""" + from datetime import datetime, timezone + + from reflector.db.recordings import Recording, recordings_controller + from reflector.db.transcripts import transcripts_controller + + # Create transcript with Whereby recording (has bucket_name, no track_keys) + transcript = await transcripts_controller.add( + "", + source_kind="room", + source_language="en", + target_language="en", + user_id="test-user", + share_mode="public", + ) + + recording = await recordings_controller.create( + Recording( + bucket_name="whereby-bucket", + object_key="test-recording.mp4", # gitleaks:allow + meeting_id="test-meeting", + recorded_at=datetime.now(timezone.utc), + track_keys=None, # Whereby recordings have no track_keys + ) + ) + + await transcripts_controller.update( + transcript, {"recording_id": recording.id, "status": "uploaded"} + ) + + with ( + patch( + "reflector.views.transcripts_process.task_pipeline_file_process" + ) as mock_file_pipeline, + patch( + "reflector.views.transcripts_process.task_pipeline_multitrack_process" + ) as mock_multitrack_pipeline, + ): + response = await client.post(f"/transcripts/{transcript.id}/process") + + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + # Whereby recordings should use file pipeline + mock_file_pipeline.delay.assert_called_once_with(transcript_id=transcript.id) + mock_multitrack_pipeline.delay.assert_not_called() + + +@pytest.mark.usefixtures("setup_database") +@pytest.mark.asyncio +async def test_dailyco_recording_uses_multitrack_pipeline(client): + """Test that Daily.co recordings (bucket_name + track_keys) use multitrack pipeline""" + from datetime import datetime, timezone + + from reflector.db.recordings import Recording, recordings_controller + from reflector.db.transcripts import transcripts_controller + + # Create transcript with Daily.co multitrack recording + transcript = await transcripts_controller.add( + "", + source_kind="room", + source_language="en", + target_language="en", + user_id="test-user", + share_mode="public", + ) + + track_keys = [ + "recordings/test-room/track1.webm", + "recordings/test-room/track2.webm", + ] + recording = await recordings_controller.create( + Recording( + bucket_name="daily-bucket", + object_key="recordings/test-room", + meeting_id="test-meeting", + track_keys=track_keys, + recorded_at=datetime.now(timezone.utc), + ) + ) + + await transcripts_controller.update( + transcript, {"recording_id": recording.id, "status": "uploaded"} + ) + + with ( + patch( + "reflector.views.transcripts_process.task_pipeline_file_process" + ) as mock_file_pipeline, + patch( + "reflector.views.transcripts_process.task_pipeline_multitrack_process" + ) as mock_multitrack_pipeline, + ): + response = await client.post(f"/transcripts/{transcript.id}/process") + + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + # Daily.co multitrack recordings should use multitrack pipeline + mock_multitrack_pipeline.delay.assert_called_once_with( + transcript_id=transcript.id, + bucket_name="daily-bucket", + track_keys=track_keys, + ) + mock_file_pipeline.delay.assert_not_called() diff --git a/server/tests/test_transcripts_recording_deletion.py b/server/tests/test_transcripts_recording_deletion.py index 810fe567..3a632612 100644 --- a/server/tests/test_transcripts_recording_deletion.py +++ b/server/tests/test_transcripts_recording_deletion.py @@ -22,13 +22,16 @@ async def test_recording_deleted_with_transcript(): recording_id=recording.id, ) - with patch("reflector.db.transcripts.get_recordings_storage") as mock_get_storage: + with patch("reflector.db.transcripts.get_transcripts_storage") as mock_get_storage: storage_instance = mock_get_storage.return_value storage_instance.delete_file = AsyncMock() await transcripts_controller.remove_by_id(transcript.id) - storage_instance.delete_file.assert_awaited_once_with(recording.object_key) + # Should be called with bucket override + storage_instance.delete_file.assert_awaited_once_with( + recording.object_key, bucket=recording.bucket_name + ) assert await recordings_controller.get_by_id(recording.id) is None assert await transcripts_controller.get_by_id(transcript.id) is None diff --git a/server/tests/test_transcripts_speaker.py b/server/tests/test_transcripts_speaker.py index d18c5072..e85eb1c7 100644 --- a/server/tests/test_transcripts_speaker.py +++ b/server/tests/test_transcripts_speaker.py @@ -2,7 +2,9 @@ import pytest @pytest.mark.asyncio -async def test_transcript_reassign_speaker(fake_transcript_with_topics, client): +async def test_transcript_reassign_speaker( + authenticated_client, fake_transcript_with_topics, client +): transcript_id = fake_transcript_with_topics.id # check the transcript exists @@ -114,7 +116,9 @@ async def test_transcript_reassign_speaker(fake_transcript_with_topics, client): @pytest.mark.asyncio -async def test_transcript_merge_speaker(fake_transcript_with_topics, client): +async def test_transcript_merge_speaker( + authenticated_client, fake_transcript_with_topics, client +): transcript_id = fake_transcript_with_topics.id # check the transcript exists @@ -181,7 +185,7 @@ async def test_transcript_merge_speaker(fake_transcript_with_topics, client): @pytest.mark.asyncio async def test_transcript_reassign_with_participant( - fake_transcript_with_topics, client + authenticated_client, fake_transcript_with_topics, client ): transcript_id = fake_transcript_with_topics.id @@ -347,7 +351,9 @@ async def test_transcript_reassign_with_participant( @pytest.mark.asyncio -async def test_transcript_reassign_edge_cases(fake_transcript_with_topics, client): +async def test_transcript_reassign_edge_cases( + authenticated_client, fake_transcript_with_topics, client +): transcript_id = fake_transcript_with_topics.id # check the transcript exists diff --git a/server/tests/test_transcripts_upload.py b/server/tests/test_transcripts_upload.py index ee08b1be..e9a90c7a 100644 --- a/server/tests/test_transcripts_upload.py +++ b/server/tests/test_transcripts_upload.py @@ -12,7 +12,8 @@ async def test_transcript_upload_file( tmpdir, dummy_llm, dummy_processors, - dummy_diarization, + dummy_file_transcript, + dummy_file_diarization, dummy_storage, client, ): @@ -36,8 +37,8 @@ async def test_transcript_upload_file( assert response.status_code == 200 assert response.json()["status"] == "ok" - # wait the processing to finish (max 10 minutes) - timeout_seconds = 600 # 10 minutes + # wait the processing to finish (max 1 minute) + timeout_seconds = 60 start_time = time.monotonic() while (time.monotonic() - start_time) < timeout_seconds: # fetch the transcript and check if it is ended @@ -47,7 +48,7 @@ async def test_transcript_upload_file( break await asyncio.sleep(1) else: - pytest.fail(f"Processing timed out after {timeout_seconds} seconds") + return pytest.fail(f"Processing timed out after {timeout_seconds} seconds") # check the transcript is ended transcript = resp.json() @@ -59,4 +60,4 @@ async def test_transcript_upload_file( response = await client.get(f"/transcripts/{tid}/topics") assert response.status_code == 200 assert len(response.json()) == 1 - assert "want to share" in response.json()[0]["transcript"] + assert "Hello world. How are you today?" in response.json()[0]["transcript"] diff --git a/server/tests/test_user_api_keys.py b/server/tests/test_user_api_keys.py new file mode 100644 index 00000000..b92466b7 --- /dev/null +++ b/server/tests/test_user_api_keys.py @@ -0,0 +1,70 @@ +import pytest + +from reflector.db.user_api_keys import user_api_keys_controller + + +@pytest.mark.asyncio +async def test_api_key_creation_and_verification(): + api_key_model, plaintext = await user_api_keys_controller.create_key( + user_id="test_user", + name="Test API Key", + ) + + verified = await user_api_keys_controller.verify_key(plaintext) + assert verified is not None + assert verified.user_id == "test_user" + assert verified.name == "Test API Key" + + invalid = await user_api_keys_controller.verify_key("fake_key") + assert invalid is None + + +@pytest.mark.asyncio +async def test_api_key_hashing(): + _, plaintext = await user_api_keys_controller.create_key( + user_id="test_user_2", + ) + + api_keys = await user_api_keys_controller.list_by_user_id("test_user_2") + assert len(api_keys) == 1 + assert api_keys[0].key_hash != plaintext + + +@pytest.mark.asyncio +async def test_generate_api_key_uniqueness(): + key1 = user_api_keys_controller.generate_key() + key2 = user_api_keys_controller.generate_key() + assert key1 != key2 + + +@pytest.mark.asyncio +async def test_hash_api_key_deterministic(): + key = "test_key_123" + hash1 = user_api_keys_controller.hash_key(key) + hash2 = user_api_keys_controller.hash_key(key) + assert hash1 == hash2 + + +@pytest.mark.asyncio +async def test_get_by_user_id_empty(): + api_keys = await user_api_keys_controller.list_by_user_id("nonexistent_user") + assert api_keys == [] + + +@pytest.mark.asyncio +async def test_get_by_user_id_multiple(): + user_id = "multi_key_user" + + _, plaintext1 = await user_api_keys_controller.create_key( + user_id=user_id, + name="API Key 1", + ) + _, plaintext2 = await user_api_keys_controller.create_key( + user_id=user_id, + name="API Key 2", + ) + + api_keys = await user_api_keys_controller.list_by_user_id(user_id) + assert len(api_keys) == 2 + names = {k.name for k in api_keys} + assert names == {"API Key 1", "API Key 2"} diff --git a/server/tests/test_user_websocket_auth.py b/server/tests/test_user_websocket_auth.py new file mode 100644 index 00000000..be1a2816 --- /dev/null +++ b/server/tests/test_user_websocket_auth.py @@ -0,0 +1,156 @@ +import asyncio +import threading +import time + +import pytest +from httpx_ws import aconnect_ws +from uvicorn import Config, Server + + +@pytest.fixture +def appserver_ws_user(setup_database): + from reflector.app import app + from reflector.db import get_database + + host = "127.0.0.1" + port = 1257 + server_started = threading.Event() + server_exception = None + server_instance = None + + def run_server(): + nonlocal server_exception, server_instance + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + config = Config(app=app, host=host, port=port, loop=loop) + server_instance = Server(config) + + async def start_server(): + database = get_database() + await database.connect() + try: + await server_instance.serve() + finally: + await database.disconnect() + + server_started.set() + loop.run_until_complete(start_server()) + except Exception as e: + server_exception = e + server_started.set() + finally: + loop.close() + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + server_started.wait(timeout=30) + if server_exception: + raise server_exception + + time.sleep(0.5) + + yield host, port + + if server_instance: + server_instance.should_exit = True + server_thread.join(timeout=30) + + +@pytest.fixture(autouse=True) +def patch_jwt_verification(monkeypatch): + """Patch JWT verification to accept HS256 tokens signed with SECRET_KEY for tests.""" + from jose import jwt + + from reflector.settings import settings + + def _verify_token(self, token: str): + # Do not validate audience in tests + return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) # type: ignore[arg-type] + + monkeypatch.setattr( + "reflector.auth.auth_jwt.JWTAuth.verify_token", _verify_token, raising=True + ) + + +def _make_dummy_jwt(sub: str = "user123") -> str: + # Create a short HS256 JWT using the app secret to pass verification in tests + from datetime import datetime, timedelta, timezone + + from jose import jwt + + from reflector.settings import settings + + payload = { + "sub": sub, + "email": f"{sub}@example.com", + "exp": datetime.now(timezone.utc) + timedelta(minutes=5), + } + # Note: production uses RS256 public key verification; tests can sign with SECRET_KEY + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + +@pytest.mark.asyncio +async def test_user_ws_rejects_missing_subprotocol(appserver_ws_user): + host, port = appserver_ws_user + base_ws = f"http://{host}:{port}/v1/events" + # No subprotocol/header with token + with pytest.raises(Exception): + async with aconnect_ws(base_ws) as ws: # type: ignore + # Should close during handshake; if not, close explicitly + await ws.close() + + +@pytest.mark.asyncio +async def test_user_ws_rejects_invalid_token(appserver_ws_user): + host, port = appserver_ws_user + base_ws = f"http://{host}:{port}/v1/events" + + # Send wrong token via WebSocket subprotocols + protocols = ["bearer", "totally-invalid-token"] + with pytest.raises(Exception): + async with aconnect_ws(base_ws, subprotocols=protocols) as ws: # type: ignore + await ws.close() + + +@pytest.mark.asyncio +async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user): + host, port = appserver_ws_user + base_ws = f"http://{host}:{port}/v1/events" + + token = _make_dummy_jwt("user-abc") + subprotocols = ["bearer", token] + + # Connect and then trigger an event via HTTP create + async with aconnect_ws(base_ws, subprotocols=subprotocols) as ws: + # Emit an event to the user's room via a standard HTTP action + from httpx import AsyncClient + + from reflector.app import app + from reflector.auth import current_user, current_user_optional + + # Override auth dependencies so HTTP request is performed as the same user + app.dependency_overrides[current_user] = lambda: { + "sub": "user-abc", + "email": "user-abc@example.com", + } + app.dependency_overrides[current_user_optional] = lambda: { + "sub": "user-abc", + "email": "user-abc@example.com", + } + + async with AsyncClient(app=app, base_url=f"http://{host}:{port}/v1") as ac: + # Create a transcript as this user so that the server publishes TRANSCRIPT_CREATED to user room + resp = await ac.post("/transcripts", json={"name": "WS Test"}) + assert resp.status_code == 200 + + # Receive the published event + msg = await ws.receive_json() + assert msg["event"] == "TRANSCRIPT_CREATED" + assert "id" in msg["data"] + + # Clean overrides + del app.dependency_overrides[current_user] + del app.dependency_overrides[current_user_optional] diff --git a/server/tests/test_utils_daily.py b/server/tests/test_utils_daily.py new file mode 100644 index 00000000..356ffc94 --- /dev/null +++ b/server/tests/test_utils_daily.py @@ -0,0 +1,17 @@ +import pytest + +from reflector.utils.daily import extract_base_room_name + + +@pytest.mark.parametrize( + "daily_room_name,expected", + [ + ("daily-20251020193458", "daily"), + ("daily-2-20251020193458", "daily-2"), + ("my-room-name-20251020193458", "my-room-name"), + ("room-with-numbers-123-20251020193458", "room-with-numbers-123"), + ("x-20251020193458", "x"), + ], +) +def test_extract_base_room_name(daily_room_name, expected): + assert extract_base_room_name(daily_room_name) == expected diff --git a/server/tests/test_utils_url.py b/server/tests/test_utils_url.py new file mode 100644 index 00000000..c833983c --- /dev/null +++ b/server/tests/test_utils_url.py @@ -0,0 +1,63 @@ +"""Tests for URL utility functions.""" + +from reflector.utils.url import add_query_param + + +class TestAddQueryParam: + """Test the add_query_param function.""" + + def test_add_param_to_url_without_query(self): + """Should add query param with ? to URL without existing params.""" + url = "https://example.com/room" + result = add_query_param(url, "t", "token123") + assert result == "https://example.com/room?t=token123" + + def test_add_param_to_url_with_existing_query(self): + """Should add query param with & to URL with existing params.""" + url = "https://example.com/room?existing=param" + result = add_query_param(url, "t", "token123") + assert result == "https://example.com/room?existing=param&t=token123" + + def test_add_param_to_url_with_multiple_existing_params(self): + """Should add query param to URL with multiple existing params.""" + url = "https://example.com/room?param1=value1¶m2=value2" + result = add_query_param(url, "t", "token123") + assert ( + result == "https://example.com/room?param1=value1¶m2=value2&t=token123" + ) + + def test_add_param_with_special_characters(self): + """Should properly encode special characters in param value.""" + url = "https://example.com/room" + result = add_query_param(url, "name", "hello world") + assert result == "https://example.com/room?name=hello+world" + + def test_add_param_to_url_with_fragment(self): + """Should preserve URL fragment when adding query param.""" + url = "https://example.com/room#section" + result = add_query_param(url, "t", "token123") + assert result == "https://example.com/room?t=token123#section" + + def test_add_param_to_url_with_query_and_fragment(self): + """Should preserve fragment when adding param to URL with existing query.""" + url = "https://example.com/room?existing=param#section" + result = add_query_param(url, "t", "token123") + assert result == "https://example.com/room?existing=param&t=token123#section" + + def test_add_param_overwrites_existing_param(self): + """Should overwrite existing param with same name.""" + url = "https://example.com/room?t=oldtoken" + result = add_query_param(url, "t", "newtoken") + assert result == "https://example.com/room?t=newtoken" + + def test_url_without_scheme(self): + """Should handle URLs without scheme (relative URLs).""" + url = "/room/path" + result = add_query_param(url, "t", "token123") + assert result == "/room/path?t=token123" + + def test_empty_url(self): + """Should handle empty URL.""" + url = "" + result = add_query_param(url, "t", "token123") + assert result == "?t=token123" diff --git a/server/tests/test_video_platforms_factory.py b/server/tests/test_video_platforms_factory.py new file mode 100644 index 00000000..6c8c02c5 --- /dev/null +++ b/server/tests/test_video_platforms_factory.py @@ -0,0 +1,58 @@ +"""Tests for video_platforms.factory module.""" + +from unittest.mock import patch + +from reflector.video_platforms.factory import get_platform + + +class TestGetPlatformF: + """Test suite for get_platform function.""" + + @patch("reflector.video_platforms.factory.settings") + def test_with_room_platform(self, mock_settings): + """When room_platform provided, should return room_platform.""" + mock_settings.DEFAULT_VIDEO_PLATFORM = "whereby" + + # Should return the room's platform when provided + assert get_platform(room_platform="daily") == "daily" + assert get_platform(room_platform="whereby") == "whereby" + + @patch("reflector.video_platforms.factory.settings") + def test_without_room_platform_uses_default(self, mock_settings): + """When no room_platform, should return DEFAULT_VIDEO_PLATFORM.""" + mock_settings.DEFAULT_VIDEO_PLATFORM = "whereby" + + # Should return default when room_platform is None + assert get_platform(room_platform=None) == "whereby" + + @patch("reflector.video_platforms.factory.settings") + def test_with_daily_default(self, mock_settings): + """When DEFAULT_VIDEO_PLATFORM is 'daily', should return 'daily' when no room_platform.""" + mock_settings.DEFAULT_VIDEO_PLATFORM = "daily" + + # Should return default 'daily' when room_platform is None + assert get_platform(room_platform=None) == "daily" + + @patch("reflector.video_platforms.factory.settings") + def test_no_room_id_provided(self, mock_settings): + """Should work correctly even when room_id is not provided.""" + mock_settings.DEFAULT_VIDEO_PLATFORM = "whereby" + + # Should use room_platform when provided + assert get_platform(room_platform="daily") == "daily" + + # Should use default when room_platform not provided + assert get_platform(room_platform=None) == "whereby" + + @patch("reflector.video_platforms.factory.settings") + def test_room_platform_always_takes_precedence(self, mock_settings): + """room_platform should always be used when provided.""" + mock_settings.DEFAULT_VIDEO_PLATFORM = "whereby" + + # room_platform should take precedence over default + assert get_platform(room_platform="daily") == "daily" + assert get_platform(room_platform="whereby") == "whereby" + + # Different default shouldn't matter when room_platform provided + mock_settings.DEFAULT_VIDEO_PLATFORM = "daily" + assert get_platform(room_platform="whereby") == "whereby" diff --git a/server/uv.lock b/server/uv.lock index 5604f922..2c28f61b 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -2,13 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.13" resolution-markers = [ - "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'", - "python_full_version < '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", "python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'", ] @@ -1035,27 +1037,27 @@ wheels = [ [[package]] name = "fonttools" -version = "4.59.1" +version = "4.59.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/7f/29c9c3fe4246f6ad96fee52b88d0dc3a863c7563b0afc959e36d78b965dc/fonttools-4.59.1.tar.gz", hash = "sha256:74995b402ad09822a4c8002438e54940d9f1ecda898d2bb057729d7da983e4cb", size = 3534394, upload-time = "2025-08-14T16:28:14.266Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/a5/fba25f9fbdab96e26dedcaeeba125e5f05a09043bf888e0305326e55685b/fonttools-4.59.2.tar.gz", hash = "sha256:e72c0749b06113f50bcb80332364c6be83a9582d6e3db3fe0b280f996dc2ef22", size = 3540889, upload-time = "2025-08-27T16:40:30.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/62/9667599561f623d4a523cc9eb4f66f3b94b6155464110fa9aebbf90bbec7/fonttools-4.59.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4909cce2e35706f3d18c54d3dcce0414ba5e0fb436a454dffec459c61653b513", size = 2778815, upload-time = "2025-08-14T16:26:28.484Z" }, - { url = "https://files.pythonhosted.org/packages/8f/78/cc25bcb2ce86033a9df243418d175e58f1956a35047c685ef553acae67d6/fonttools-4.59.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:efbec204fa9f877641747f2d9612b2b656071390d7a7ef07a9dbf0ecf9c7195c", size = 2341631, upload-time = "2025-08-14T16:26:30.396Z" }, - { url = "https://files.pythonhosted.org/packages/a4/cc/fcbb606dd6871f457ac32f281c20bcd6cc77d9fce77b5a4e2b2afab1f500/fonttools-4.59.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39dfd42cc2dc647b2c5469bc7a5b234d9a49e72565b96dd14ae6f11c2c59ef15", size = 5022222, upload-time = "2025-08-14T16:26:32.447Z" }, - { url = "https://files.pythonhosted.org/packages/61/96/c0b1cf2b74d08eb616a80dbf5564351fe4686147291a25f7dce8ace51eb3/fonttools-4.59.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b11bc177a0d428b37890825d7d025040d591aa833f85f8d8878ed183354f47df", size = 4966512, upload-time = "2025-08-14T16:26:34.621Z" }, - { url = "https://files.pythonhosted.org/packages/a4/26/51ce2e3e0835ffc2562b1b11d1fb9dafd0aca89c9041b64a9e903790a761/fonttools-4.59.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b9b4c35b3be45e5bc774d3fc9608bbf4f9a8d371103b858c80edbeed31dd5aa", size = 5001645, upload-time = "2025-08-14T16:26:36.876Z" }, - { url = "https://files.pythonhosted.org/packages/36/11/ef0b23f4266349b6d5ccbd1a07b7adc998d5bce925792aa5d1ec33f593e3/fonttools-4.59.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:01158376b8a418a0bae9625c476cebfcfcb5e6761e9d243b219cd58341e7afbb", size = 5113777, upload-time = "2025-08-14T16:26:39.002Z" }, - { url = "https://files.pythonhosted.org/packages/d0/da/b398fe61ef433da0a0472cdb5d4399124f7581ffe1a31b6242c91477d802/fonttools-4.59.1-cp311-cp311-win32.whl", hash = "sha256:cf7c5089d37787387123f1cb8f1793a47c5e1e3d1e4e7bfbc1cc96e0f925eabe", size = 2215076, upload-time = "2025-08-14T16:26:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/94/bd/e2624d06ab94e41c7c77727b2941f1baed7edb647e63503953e6888020c9/fonttools-4.59.1-cp311-cp311-win_amd64.whl", hash = "sha256:c866eef7a0ba320486ade6c32bfc12813d1a5db8567e6904fb56d3d40acc5116", size = 2262779, upload-time = "2025-08-14T16:26:43.483Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fe/6e069cc4cb8881d164a9bd956e9df555bc62d3eb36f6282e43440200009c/fonttools-4.59.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:43ab814bbba5f02a93a152ee61a04182bb5809bd2bc3609f7822e12c53ae2c91", size = 2769172, upload-time = "2025-08-14T16:26:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/b9/98/ec4e03f748fefa0dd72d9d95235aff6fef16601267f4a2340f0e16b9330f/fonttools-4.59.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4f04c3ffbfa0baafcbc550657cf83657034eb63304d27b05cff1653b448ccff6", size = 2337281, upload-time = "2025-08-14T16:26:47.921Z" }, - { url = "https://files.pythonhosted.org/packages/8b/b1/890360a7e3d04a30ba50b267aca2783f4c1364363797e892e78a4f036076/fonttools-4.59.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d601b153e51a5a6221f0d4ec077b6bfc6ac35bfe6c19aeaa233d8990b2b71726", size = 4909215, upload-time = "2025-08-14T16:26:49.682Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ec/2490599550d6c9c97a44c1e36ef4de52d6acf742359eaa385735e30c05c4/fonttools-4.59.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c735e385e30278c54f43a0d056736942023c9043f84ee1021eff9fd616d17693", size = 4951958, upload-time = "2025-08-14T16:26:51.616Z" }, - { url = "https://files.pythonhosted.org/packages/d1/40/bd053f6f7634234a9b9805ff8ae4f32df4f2168bee23cafd1271ba9915a9/fonttools-4.59.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1017413cdc8555dce7ee23720da490282ab7ec1cf022af90a241f33f9a49afc4", size = 4894738, upload-time = "2025-08-14T16:26:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a1/3cd12a010d288325a7cfcf298a84825f0f9c29b01dee1baba64edfe89257/fonttools-4.59.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5c6d8d773470a5107052874341ed3c487c16ecd179976d81afed89dea5cd7406", size = 5045983, upload-time = "2025-08-14T16:26:56.153Z" }, - { url = "https://files.pythonhosted.org/packages/a2/af/8a2c3f6619cc43cf87951405337cc8460d08a4e717bb05eaa94b335d11dc/fonttools-4.59.1-cp312-cp312-win32.whl", hash = "sha256:2a2d0d33307f6ad3a2086a95dd607c202ea8852fa9fb52af9b48811154d1428a", size = 2203407, upload-time = "2025-08-14T16:26:58.165Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f2/a19b874ddbd3ebcf11d7e25188ef9ac3f68b9219c62263acb34aca8cde05/fonttools-4.59.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b9e4fa7eaf046ed6ac470f6033d52c052481ff7a6e0a92373d14f556f298dc0", size = 2251561, upload-time = "2025-08-14T16:27:00.646Z" }, - { url = "https://files.pythonhosted.org/packages/0f/64/9d606e66d498917cd7a2ff24f558010d42d6fd4576d9dd57f0bd98333f5a/fonttools-4.59.1-py3-none-any.whl", hash = "sha256:647db657073672a8330608970a984d51573557f328030566521bc03415535042", size = 1130094, upload-time = "2025-08-14T16:28:12.048Z" }, + { url = "https://files.pythonhosted.org/packages/f8/53/742fcd750ae0bdc74de4c0ff923111199cc2f90a4ee87aaddad505b6f477/fonttools-4.59.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:511946e8d7ea5c0d6c7a53c4cb3ee48eda9ab9797cd9bf5d95829a398400354f", size = 2774961, upload-time = "2025-08-27T16:38:47.536Z" }, + { url = "https://files.pythonhosted.org/packages/57/2a/976f5f9fa3b4dd911dc58d07358467bec20e813d933bc5d3db1a955dd456/fonttools-4.59.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5e2682cf7be766d84f462ba8828d01e00c8751a8e8e7ce12d7784ccb69a30d", size = 2344690, upload-time = "2025-08-27T16:38:49.723Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b7eefc274fcf370911e292e95565c8253b0b87c82a53919ab3c795a4f50e/fonttools-4.59.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5729e12a982dba3eeae650de48b06f3b9ddb51e9aee2fcaf195b7d09a96250e2", size = 5026910, upload-time = "2025-08-27T16:38:51.904Z" }, + { url = "https://files.pythonhosted.org/packages/69/95/864726eaa8f9d4e053d0c462e64d5830ec7c599cbdf1db9e40f25ca3972e/fonttools-4.59.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c52694eae5d652361d59ecdb5a2246bff7cff13b6367a12da8499e9df56d148d", size = 4971031, upload-time = "2025-08-27T16:38:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/b8c4735ebdea20696277c70c79e0de615dbe477834e5a7c2569aa1db4033/fonttools-4.59.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f1bbc23ba1312bd8959896f46f667753b90216852d2a8cfa2d07e0cb234144", size = 5006112, upload-time = "2025-08-27T16:38:55.69Z" }, + { url = "https://files.pythonhosted.org/packages/3b/23/f9ea29c292aa2fc1ea381b2e5621ac436d5e3e0a5dee24ffe5404e58eae8/fonttools-4.59.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a1bfe5378962825dabe741720885e8b9ae9745ec7ecc4a5ec1f1ce59a6062bf", size = 5117671, upload-time = "2025-08-27T16:38:58.984Z" }, + { url = "https://files.pythonhosted.org/packages/ba/07/cfea304c555bf06e86071ff2a3916bc90f7c07ec85b23bab758d4908c33d/fonttools-4.59.2-cp311-cp311-win32.whl", hash = "sha256:e937790f3c2c18a1cbc7da101550a84319eb48023a715914477d2e7faeaba570", size = 2218157, upload-time = "2025-08-27T16:39:00.75Z" }, + { url = "https://files.pythonhosted.org/packages/d7/de/35d839aa69db737a3f9f3a45000ca24721834d40118652a5775d5eca8ebb/fonttools-4.59.2-cp311-cp311-win_amd64.whl", hash = "sha256:9836394e2f4ce5f9c0a7690ee93bd90aa1adc6b054f1a57b562c5d242c903104", size = 2265846, upload-time = "2025-08-27T16:39:02.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3d/1f45db2df51e7bfa55492e8f23f383d372200be3a0ded4bf56a92753dd1f/fonttools-4.59.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82906d002c349cad647a7634b004825a7335f8159d0d035ae89253b4abf6f3ea", size = 2769711, upload-time = "2025-08-27T16:39:04.423Z" }, + { url = "https://files.pythonhosted.org/packages/29/df/cd236ab32a8abfd11558f296e064424258db5edefd1279ffdbcfd4fd8b76/fonttools-4.59.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a10c1bd7644dc58f8862d8ba0cf9fb7fef0af01ea184ba6ce3f50ab7dfe74d5a", size = 2340225, upload-time = "2025-08-27T16:39:06.143Z" }, + { url = "https://files.pythonhosted.org/packages/98/12/b6f9f964fe6d4b4dd4406bcbd3328821c3de1f909ffc3ffa558fe72af48c/fonttools-4.59.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:738f31f23e0339785fd67652a94bc69ea49e413dfdb14dcb8c8ff383d249464e", size = 4912766, upload-time = "2025-08-27T16:39:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/82bde2f2d2c306ef3909b927363170b83df96171f74e0ccb47ad344563cd/fonttools-4.59.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ec99f9bdfee9cdb4a9172f9e8fd578cce5feb231f598909e0aecf5418da4f25", size = 4955178, upload-time = "2025-08-27T16:39:10.094Z" }, + { url = "https://files.pythonhosted.org/packages/92/77/7de766afe2d31dda8ee46d7e479f35c7d48747e558961489a2d6e3a02bd4/fonttools-4.59.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0476ea74161322e08c7a982f83558a2b81b491509984523a1a540baf8611cc31", size = 4897898, upload-time = "2025-08-27T16:39:12.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/77/ce0e0b905d62a06415fda9f2b2e109a24a5db54a59502b769e9e297d2242/fonttools-4.59.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95922a922daa1f77cc72611747c156cfb38030ead72436a2c551d30ecef519b9", size = 5049144, upload-time = "2025-08-27T16:39:13.84Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/870d93aefd23fff2e07cbeebdc332527868422a433c64062c09d4d5e7fe6/fonttools-4.59.2-cp312-cp312-win32.whl", hash = "sha256:39ad9612c6a622726a6a130e8ab15794558591f999673f1ee7d2f3d30f6a3e1c", size = 2206473, upload-time = "2025-08-27T16:39:15.854Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/e44bad000c4a4bb2e9ca11491d266e857df98ab6d7428441b173f0fe2517/fonttools-4.59.2-cp312-cp312-win_amd64.whl", hash = "sha256:980fd7388e461b19a881d35013fec32c713ffea1fc37aef2f77d11f332dfd7da", size = 2254706, upload-time = "2025-08-27T16:39:17.893Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/d2f7be3c86708912c02571db0b550121caab8cd88a3c0aacb9cfa15ea66e/fonttools-4.59.2-py3-none-any.whl", hash = "sha256:8bd0f759020e87bb5d323e6283914d9bf4ae35a7307dafb2cbd1e379e720ad37", size = 1132315, upload-time = "2025-08-27T16:40:28.984Z" }, ] [[package]] @@ -1307,6 +1309,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/c9/751b6401887f4b50f9307cc1e53d287b3dc77c375c126aeb6335aff73ccb/HyperPyYAML-1.2.2-py3-none-any.whl", hash = "sha256:3c5864bdc8864b2f0fbd7bc495e7e8fdf2dfd5dd80116f72da27ca96a128bdeb", size = 16118, upload-time = "2023-09-21T14:45:25.101Z" }, ] +[[package]] +name = "icalendar" +version = "6.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/13/e5899c916dcf1343ea65823eb7278d3e1a1d679f383f6409380594b5f322/icalendar-6.3.1.tar.gz", hash = "sha256:a697ce7b678072941e519f2745704fc29d78ef92a2dc53d9108ba6a04aeba466", size = 177169, upload-time = "2025-05-20T07:42:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/25/b5fc00e85d2dfaf5c806ac8b5f1de072fa11630c5b15b4ae5bbc228abd51/icalendar-6.3.1-py3-none-any.whl", hash = "sha256:7ea1d1b212df685353f74cdc6ec9646bf42fa557d1746ea645ce8779fdfbecdd", size = 242349, upload-time = "2025-05-20T07:42:48.589Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -1325,15 +1340,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, ] -[[package]] -name = "inflection" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, -] - [[package]] name = "iniconfig" version = "2.1.0" @@ -1558,7 +1564,7 @@ wheels = [ [[package]] name = "lightning" -version = "2.5.3" +version = "2.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec", extra = ["http"] }, @@ -1572,9 +1578,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/80/dddb5a382aa0ff18045aee6491f81e40371102cb05da2ad5a8436a51c475/lightning-2.5.3.tar.gz", hash = "sha256:4ed3e12369a1e0f928beecf5c9f5efdabda60a9216057954851e2d89f1abecde", size = 636577, upload-time = "2025-08-13T20:29:32.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/dd/86bb3bebadcdbc6e6e5a63657f0a03f74cd065b5ea965896679f76fec0b4/lightning-2.5.5.tar.gz", hash = "sha256:4d3d66c5b1481364a7e6a1ce8ddde1777a04fa740a3145ec218a9941aed7dd30", size = 640770, upload-time = "2025-09-05T16:01:21.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/6b/00e9c2b03a449c21d7a4d73a7104ac94f56c37a1e6eae77b1c702d8dddf0/lightning-2.5.3-py3-none-any.whl", hash = "sha256:c551111fda0db0bce267791f9a90cd4f9cf94bc327d36348af0ef79ec752d666", size = 824181, upload-time = "2025-08-13T20:29:30.244Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d0/4b4fbafc3b18df91207a6e46782d9fd1905f9f45cb2c3b8dfbb239aef781/lightning-2.5.5-py3-none-any.whl", hash = "sha256:69eb248beadd7b600bf48eff00a0ec8af171ec7a678d23787c4aedf12e225e8f", size = 828490, upload-time = "2025-09-05T16:01:17.845Z" }, ] [[package]] @@ -1874,19 +1880,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/24/8497595be04a8a0209536e9ce70d4132f8f8e001986f4c700414b3777758/llama_parse-0.6.43-py3-none-any.whl", hash = "sha256:fe435309638c4fdec4fec31f97c5031b743c92268962d03b99bd76704f566c32", size = 4944, upload-time = "2025-07-08T18:20:57.089Z" }, ] -[[package]] -name = "loguru" -version = "0.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, -] - [[package]] name = "mako" version = "1.3.10" @@ -1953,7 +1946,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.10.5" +version = "3.10.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy" }, @@ -1966,25 +1959,25 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/91/f2939bb60b7ebf12478b030e0d7f340247390f402b3b189616aad790c366/matplotlib-3.10.5.tar.gz", hash = "sha256:352ed6ccfb7998a00881692f38b4ca083c691d3e275b4145423704c34c909076", size = 34804044, upload-time = "2025-07-31T18:09:33.805Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/c7/1f2db90a1d43710478bb1e9b57b162852f79234d28e4f48a28cc415aa583/matplotlib-3.10.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dcfc39c452c6a9f9028d3e44d2d721484f665304857188124b505b2c95e1eecf", size = 8239216, upload-time = "2025-07-31T18:07:51.947Z" }, - { url = "https://files.pythonhosted.org/packages/82/6d/ca6844c77a4f89b1c9e4d481c412e1d1dbabf2aae2cbc5aa2da4a1d6683e/matplotlib-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:903352681b59f3efbf4546985142a9686ea1d616bb054b09a537a06e4b892ccf", size = 8102130, upload-time = "2025-07-31T18:07:53.65Z" }, - { url = "https://files.pythonhosted.org/packages/1d/1e/5e187a30cc673a3e384f3723e5f3c416033c1d8d5da414f82e4e731128ea/matplotlib-3.10.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:080c3676a56b8ee1c762bcf8fca3fe709daa1ee23e6ef06ad9f3fc17332f2d2a", size = 8666471, upload-time = "2025-07-31T18:07:55.304Z" }, - { url = "https://files.pythonhosted.org/packages/03/c0/95540d584d7d645324db99a845ac194e915ef75011a0d5e19e1b5cee7e69/matplotlib-3.10.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b4984d5064a35b6f66d2c11d668565f4389b1119cc64db7a4c1725bc11adffc", size = 9500518, upload-time = "2025-07-31T18:07:57.199Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2e/e019352099ea58b4169adb9c6e1a2ad0c568c6377c2b677ee1f06de2adc7/matplotlib-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3967424121d3a46705c9fa9bdb0931de3228f13f73d7bb03c999c88343a89d89", size = 9552372, upload-time = "2025-07-31T18:07:59.41Z" }, - { url = "https://files.pythonhosted.org/packages/b7/81/3200b792a5e8b354f31f4101ad7834743ad07b6d620259f2059317b25e4d/matplotlib-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:33775bbeb75528555a15ac29396940128ef5613cf9a2d31fb1bfd18b3c0c0903", size = 8100634, upload-time = "2025-07-31T18:08:01.801Z" }, - { url = "https://files.pythonhosted.org/packages/52/46/a944f6f0c1f5476a0adfa501969d229ce5ae60cf9a663be0e70361381f89/matplotlib-3.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:c61333a8e5e6240e73769d5826b9a31d8b22df76c0778f8480baf1b4b01c9420", size = 7978880, upload-time = "2025-07-31T18:08:03.407Z" }, - { url = "https://files.pythonhosted.org/packages/66/1e/c6f6bcd882d589410b475ca1fc22e34e34c82adff519caf18f3e6dd9d682/matplotlib-3.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:00b6feadc28a08bd3c65b2894f56cf3c94fc8f7adcbc6ab4516ae1e8ed8f62e2", size = 8253056, upload-time = "2025-07-31T18:08:05.385Z" }, - { url = "https://files.pythonhosted.org/packages/53/e6/d6f7d1b59413f233793dda14419776f5f443bcccb2dfc84b09f09fe05dbe/matplotlib-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee98a5c5344dc7f48dc261b6ba5d9900c008fc12beb3fa6ebda81273602cc389", size = 8110131, upload-time = "2025-07-31T18:08:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/66/2b/bed8a45e74957549197a2ac2e1259671cd80b55ed9e1fe2b5c94d88a9202/matplotlib-3.10.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a17e57e33de901d221a07af32c08870ed4528db0b6059dce7d7e65c1122d4bea", size = 8669603, upload-time = "2025-07-31T18:08:09.064Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a7/315e9435b10d057f5e52dfc603cd353167ae28bb1a4e033d41540c0067a4/matplotlib-3.10.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97b9d6443419085950ee4a5b1ee08c363e5c43d7176e55513479e53669e88468", size = 9508127, upload-time = "2025-07-31T18:08:10.845Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d9/edcbb1f02ca99165365d2768d517898c22c6040187e2ae2ce7294437c413/matplotlib-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ceefe5d40807d29a66ae916c6a3915d60ef9f028ce1927b84e727be91d884369", size = 9566926, upload-time = "2025-07-31T18:08:13.186Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d9/6dd924ad5616c97b7308e6320cf392c466237a82a2040381163b7500510a/matplotlib-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:c04cba0f93d40e45b3c187c6c52c17f24535b27d545f757a2fffebc06c12b98b", size = 8107599, upload-time = "2025-07-31T18:08:15.116Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f3/522dc319a50f7b0279fbe74f86f7a3506ce414bc23172098e8d2bdf21894/matplotlib-3.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:a41bcb6e2c8e79dc99c5511ae6f7787d2fb52efd3d805fff06d5d4f667db16b2", size = 7978173, upload-time = "2025-07-31T18:08:21.518Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d6/e921be4e1a5f7aca5194e1f016cb67ec294548e530013251f630713e456d/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:160e125da27a749481eaddc0627962990f6029811dbeae23881833a011a0907f", size = 8233224, upload-time = "2025-07-31T18:09:27.512Z" }, - { url = "https://files.pythonhosted.org/packages/ec/74/a2b9b04824b9c349c8f1b2d21d5af43fa7010039427f2b133a034cb09e59/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac3d50760394d78a3c9be6b28318fe22b494c4fcf6407e8fd4794b538251899b", size = 8098539, upload-time = "2025-07-31T18:09:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/fc/66/cd29ebc7f6c0d2a15d216fb572573e8fc38bd5d6dec3bd9d7d904c0949f7/matplotlib-3.10.5-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c49465bf689c4d59d174d0c7795fb42a21d4244d11d70e52b8011987367ac61", size = 8672192, upload-time = "2025-07-31T18:09:31.407Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/5d3665aa44c49005aaacaa68ddea6fcb27345961cd538a98bb0177934ede/matplotlib-3.10.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:905b60d1cb0ee604ce65b297b61cf8be9f4e6cfecf95a3fe1c388b5266bc8f4f", size = 8257527, upload-time = "2025-08-30T00:12:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/8c/af/30ddefe19ca67eebd70047dabf50f899eaff6f3c5e6a1a7edaecaf63f794/matplotlib-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bac38d816637343e53d7185d0c66677ff30ffb131044a81898b5792c956ba76", size = 8119583, upload-time = "2025-08-30T00:12:47.236Z" }, + { url = "https://files.pythonhosted.org/packages/d3/29/4a8650a3dcae97fa4f375d46efcb25920d67b512186f8a6788b896062a81/matplotlib-3.10.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:942a8de2b5bfff1de31d95722f702e2966b8a7e31f4e68f7cd963c7cd8861cf6", size = 8692682, upload-time = "2025-08-30T00:12:48.781Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/b793b9cb061cfd5d42ff0f69d1822f8d5dbc94e004618e48a97a8373179a/matplotlib-3.10.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3276c85370bc0dfca051ec65c5817d1e0f8f5ce1b7787528ec8ed2d524bbc2f", size = 9521065, upload-time = "2025-08-30T00:12:50.602Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c5/53de5629f223c1c66668d46ac2621961970d21916a4bc3862b174eb2a88f/matplotlib-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9df5851b219225731f564e4b9e7f2ac1e13c9e6481f941b5631a0f8e2d9387ce", size = 9576888, upload-time = "2025-08-30T00:12:52.92Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/0a18d6d7d2d0a2e66585032a760d13662e5250c784d53ad50434e9560991/matplotlib-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:abb5d9478625dd9c9eb51a06d39aae71eda749ae9b3138afb23eb38824026c7e", size = 8115158, upload-time = "2025-08-30T00:12:54.863Z" }, + { url = "https://files.pythonhosted.org/packages/07/b3/1a5107bb66c261e23b9338070702597a2d374e5aa7004b7adfc754fbed02/matplotlib-3.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:886f989ccfae63659183173bb3fced7fd65e9eb793c3cc21c273add368536951", size = 7992444, upload-time = "2025-08-30T00:12:57.067Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1a/7042f7430055d567cc3257ac409fcf608599ab27459457f13772c2d9778b/matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347", size = 8272404, upload-time = "2025-08-30T00:12:59.112Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5d/1d5f33f5b43f4f9e69e6a5fe1fb9090936ae7bc8e2ff6158e7a76542633b/matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75", size = 8128262, upload-time = "2025-08-30T00:13:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/67/c3/135fdbbbf84e0979712df58e5e22b4f257b3f5e52a3c4aacf1b8abec0d09/matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95", size = 8697008, upload-time = "2025-08-30T00:13:03.24Z" }, + { url = "https://files.pythonhosted.org/packages/9c/be/c443ea428fb2488a3ea7608714b1bd85a82738c45da21b447dc49e2f8e5d/matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb", size = 9530166, upload-time = "2025-08-30T00:13:05.951Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/48441422b044d74034aea2a3e0d1a49023f12150ebc58f16600132b9bbaf/matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07", size = 9593105, upload-time = "2025-08-30T00:13:08.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/c3/994ef20eb4154ab84cc08d033834555319e4af970165e6c8894050af0b3c/matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b", size = 8122784, upload-time = "2025-08-30T00:13:10.367Z" }, + { url = "https://files.pythonhosted.org/packages/57/b8/5c85d9ae0e40f04e71bedb053aada5d6bab1f9b5399a0937afb5d6b02d98/matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa", size = 7992823, upload-time = "2025-08-30T00:13:12.24Z" }, + { url = "https://files.pythonhosted.org/packages/12/bb/02c35a51484aae5f49bd29f091286e7af5f3f677a9736c58a92b3c78baeb/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f2d684c3204fa62421bbf770ddfebc6b50130f9cad65531eeba19236d73bb488", size = 8252296, upload-time = "2025-08-30T00:14:19.49Z" }, + { url = "https://files.pythonhosted.org/packages/7d/85/41701e3092005aee9a2445f5ee3904d9dbd4a7df7a45905ffef29b7ef098/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6f4a69196e663a41d12a728fab8751177215357906436804217d6d9cf0d4d6cf", size = 8116749, upload-time = "2025-08-30T00:14:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/16/53/8d8fa0ea32a8c8239e04d022f6c059ee5e1b77517769feccd50f1df43d6d/matplotlib-3.10.6-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d6ca6ef03dfd269f4ead566ec6f3fb9becf8dab146fb999022ed85ee9f6b3eb", size = 8693933, upload-time = "2025-08-30T00:14:22.942Z" }, ] [[package]] @@ -2185,7 +2178,7 @@ wheels = [ [[package]] name = "optuna" -version = "4.4.0" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "alembic" }, @@ -2196,9 +2189,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/e0/b303190ae8032d12f320a24c42af04038bacb1f3b17ede354dd1044a5642/optuna-4.4.0.tar.gz", hash = "sha256:a9029f6a92a1d6c8494a94e45abd8057823b535c2570819072dbcdc06f1c1da4", size = 467708, upload-time = "2025-06-16T05:13:00.024Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/bcd1e5500de6ec794c085a277e5b624e60b4fac1790681d7cdbde25b93a2/optuna-4.5.0.tar.gz", hash = "sha256:264844da16dad744dea295057d8bc218646129c47567d52c35a201d9f99942ba", size = 472338, upload-time = "2025-08-18T06:49:22.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/5e/068798a8c7087863e7772e9363a880ab13fe55a5a7ede8ec42fab8a1acbb/optuna-4.4.0-py3-none-any.whl", hash = "sha256:fad8d9c5d5af993ae1280d6ce140aecc031c514a44c3b639d8c8658a8b7920ea", size = 395949, upload-time = "2025-06-16T05:12:58.37Z" }, + { url = "https://files.pythonhosted.org/packages/7f/12/cba81286cbaf0f0c3f0473846cfd992cb240bdcea816bf2ef7de8ed0f744/optuna-4.5.0-py3-none-any.whl", hash = "sha256:5b8a783e84e448b0742501bc27195344a28d2c77bd2feef5b558544d954851b0", size = 400872, upload-time = "2025-08-18T06:49:20.697Z" }, ] [[package]] @@ -2311,18 +2304,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload-time = "2018-05-29T17:18:17.53Z" }, ] -[[package]] -name = "profanityfilter" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "inflection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8d/03/08740b5e0800f9eb9f675c149a497a3f3735e7b04e414bcce64136e7e487/profanityfilter-2.1.0.tar.gz", hash = "sha256:0ede04e92a9d7255faa52b53776518edc6586dda828aca677c74b5994dfdd9d8", size = 7910, upload-time = "2024-11-25T22:31:51.194Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/03/eb18f72dc6e6398e75e3762677f18ab3a773a384b18efd3ed9119844e892/profanityfilter-2.1.0-py2.py3-none-any.whl", hash = "sha256:e1bc07012760fd74512a335abb93a36877831ed26abab78bfe31bebb68f8c844", size = 7483, upload-time = "2024-11-25T22:31:50.129Z" }, -] - [[package]] name = "prometheus-client" version = "0.22.1" @@ -2958,7 +2939,7 @@ wheels = [ [[package]] name = "pytorch-lightning" -version = "2.5.3" +version = "2.5.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec", extra = ["http"] }, @@ -2971,14 +2952,14 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/a8/31fe79bf96dab33cee5537ed6f08230ed6f032834bb4ff529cc487fb40e8/pytorch_lightning-2.5.3.tar.gz", hash = "sha256:65f4eee774ee1adba181aacacffb9f677fe5c5f9fd3d01a95f603403f940be6a", size = 639897, upload-time = "2025-08-13T20:29:39.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/78/bce84aab9a5b3b2e9d087d4f1a6be9b481adbfaac4903bc9daaaf09d49a3/pytorch_lightning-2.5.5.tar.gz", hash = "sha256:d6fc8173d1d6e49abfd16855ea05d2eb2415e68593f33d43e59028ecb4e64087", size = 643703, upload-time = "2025-09-05T16:01:18.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/a2/5f2b7b40ec5213db5282e98dd32fd419fe5b73b5b53895dfff56fe12fed0/pytorch_lightning-2.5.3-py3-none-any.whl", hash = "sha256:7476bd36282d9253dda175b9263b07942489d70ad90bbd1bc0a59c46e012f353", size = 828186, upload-time = "2025-08-13T20:29:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/f6/99a5c66478f469598dee25b0e29b302b5bddd4e03ed0da79608ac964056e/pytorch_lightning-2.5.5-py3-none-any.whl", hash = "sha256:0b533991df2353c0c6ea9ca10a7d0728b73631fd61f5a15511b19bee2aef8af0", size = 832431, upload-time = "2025-09-05T16:01:16.234Z" }, ] [[package]] name = "pytorch-metric-learning" -version = "2.8.1" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -2987,9 +2968,9 @@ dependencies = [ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/94/1bfb2c3eaf195b2d72912b65b3d417f2d9ac22491563eca360d453512c59/pytorch-metric-learning-2.8.1.tar.gz", hash = "sha256:fcc4d3b4a805e5fce25fb2e67505c47ba6fea0563fc09c5655ea1f08d1e8ed93", size = 83117, upload-time = "2024-12-11T19:21:15.982Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/80/6e61b1a91debf4c1b47d441f9a9d7fe2aabcdd9575ed70b2811474eb95c3/pytorch-metric-learning-2.9.0.tar.gz", hash = "sha256:27a626caf5e2876a0fd666605a78cb67ef7597e25d7a68c18053dd503830701f", size = 84530, upload-time = "2025-08-17T17:11:19.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/15/eee4e24c3f5a63b3e73692ff79766a66cab8844e24f5912be29350937592/pytorch_metric_learning-2.8.1-py3-none-any.whl", hash = "sha256:aba6da0508d29ee9661a67fbfee911cdf62e65fc07e404b167d82871ca7e3e88", size = 125923, upload-time = "2024-12-11T19:21:13.448Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/73ef5052f57b7720cad00e16598db3592a5ef4826745ffca67a2f085d4dc/pytorch_metric_learning-2.9.0-py3-none-any.whl", hash = "sha256:d51646006dc87168f00cf954785db133a4c5aac81253877248737aa42ef6432a", size = 127801, upload-time = "2025-08-17T17:11:18.185Z" }, ] [[package]] @@ -3125,13 +3106,12 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "fastapi-pagination" }, { name = "httpx" }, + { name = "icalendar" }, { name = "jsonschema" }, { name = "llama-index" }, { name = "llama-index-llms-openai-like" }, - { name = "loguru" }, { name = "nltk" }, { name = "openai" }, - { name = "profanityfilter" }, { name = "prometheus-fastapi-instrumentator" }, { name = "protobuf" }, { name = "psycopg2-binary" }, @@ -3202,13 +3182,12 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.100.1" }, { name = "fastapi-pagination", specifier = ">=0.12.6" }, { name = "httpx", specifier = ">=0.24.1" }, + { name = "icalendar", specifier = ">=6.0.0" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "llama-index", specifier = ">=0.12.52" }, { name = "llama-index-llms-openai-like", specifier = ">=0.4.0" }, - { name = "loguru", specifier = ">=0.7.0" }, { name = "nltk", specifier = ">=3.8.1" }, { name = "openai", specifier = ">=1.59.7" }, - { name = "profanityfilter", specifier = ">=2.0.6" }, { name = "prometheus-fastapi-instrumentator", specifier = ">=6.1.0" }, { name = "protobuf", specifier = ">=4.24.3" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, @@ -3450,14 +3429,14 @@ wheels = [ [[package]] name = "ruamel-yaml" -version = "0.18.14" +version = "0.18.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511, upload-time = "2025-06-09T08:51:09.828Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570, upload-time = "2025-06-09T08:51:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" }, ] [[package]] @@ -3644,7 +3623,7 @@ wheels = [ [[package]] name = "silero-vad" -version = "5.1.2" +version = "6.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "onnxruntime" }, @@ -3653,9 +3632,9 @@ dependencies = [ { name = "torchaudio", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'darwin')" }, { name = "torchaudio", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/b4/d0311b2e6220a11f8f4699f4a278cb088131573286cdfe804c87c7eb5123/silero_vad-5.1.2.tar.gz", hash = "sha256:c442971160026d2d7aa0ad83f0c7ee86c89797a65289fe625c8ea59fc6fb828d", size = 5098526, upload-time = "2024-10-09T09:50:47.019Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/79/ff5b13ca491a2eef2a43cd989ac9a87fa2131c246d467d909f2568c56955/silero_vad-6.0.0.tar.gz", hash = "sha256:4d202cb662112d9cba0e3fbc9f2c67e2e265c853f319adf20e348d108c797b76", size = 14567206, upload-time = "2025-08-26T07:10:02.571Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/f7/5ae11d13fbb733cd3bfd7ff1c3a3902e6f55437df4b72307c1f168146268/silero_vad-5.1.2-py3-none-any.whl", hash = "sha256:93b41953d7774b165407fda6b533c119c5803864e367d5034dc626c82cfdf661", size = 5026737, upload-time = "2024-10-09T09:50:44.355Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6a/a0a024878a1933a2326c42a3ce24fff6c0bf4882655f156c960ba50c2ed4/silero_vad-6.0.0-py3-none-any.whl", hash = "sha256:37d29be8944d2a2e6f1cc38a066076f13e78e6fc1b567a1beddcca72096f077f", size = 6119146, upload-time = "2025-08-26T07:10:00.637Z" }, ] [[package]] @@ -3954,8 +3933,8 @@ dependencies = [ { name = "typing-extensions", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:3d05017d19bc99741288e458888283a44b0ee881d53f05f72f8b1cfea8998122" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl" }, ] [[package]] @@ -3963,12 +3942,14 @@ name = "torch" version = "2.8.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", - "python_full_version < '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", "python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", ] dependencies = [ { name = "filelock", marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" }, @@ -3980,16 +3961,16 @@ dependencies = [ { name = "typing-extensions", marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:2bfc013dd6efdc8f8223a0241d3529af9f315dffefb53ffa3bf14d3f10127da6" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:680129efdeeec3db5da3f88ee5d28c1b1e103b774aef40f9d638e2cce8f8d8d8" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cb06175284673a581dd91fb1965662ae4ecaba6e5c357aa0ea7bb8b84b6b7eeb" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7631ef49fbd38d382909525b83696dc12a55d68492ade4ace3883c62b9fc140f" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:41e6fc5ec0914fcdce44ccf338b1d19a441b55cafdd741fd0bf1af3f9e4cfd14" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d" }, - { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-linux_s390x.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_arm64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl" }, ] [[package]] @@ -4052,10 +4033,12 @@ name = "torchaudio" version = "2.8.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version < '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", "(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", ] dependencies = [ { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, @@ -4069,7 +4052,7 @@ wheels = [ [[package]] name = "torchmetrics" -version = "1.8.1" +version = "1.8.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lightning-utilities" }, @@ -4078,9 +4061,9 @@ dependencies = [ { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" }, { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/1f/2cd9eb8f3390c3ec4693ac0871913d4b468964b3833638e4091a70817e0a/torchmetrics-1.8.1.tar.gz", hash = "sha256:04ca021105871637c5d34d0a286b3ab665a1e3d2b395e561f14188a96e862fdb", size = 580373, upload-time = "2025-08-07T20:44:44.631Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679, upload-time = "2025-09-03T14:00:54.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/59/5c1c1cb08c494621901cf549a543f87143019fac1e6dd191eb4630bbc8fb/torchmetrics-1.8.1-py3-none-any.whl", hash = "sha256:2437501351e0da3d294c71210ce8139b9c762b5e20604f7a051a725443db8f4b", size = 982961, upload-time = "2025-08-07T20:44:42.608Z" }, + { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161, upload-time = "2025-09-03T14:00:51.921Z" }, ] [[package]] @@ -4232,8 +4215,10 @@ name = "vcrpy" version = "5.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'", - "python_full_version < '3.12' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", + "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'", + "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", ] dependencies = [ { name = "pyyaml", marker = "platform_python_implementation == 'PyPy'" }, @@ -4367,15 +4352,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/ed/aad7e0f5a462d679f7b4d2e0d8502c3096740c883b5bbed5103146480937/webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7", size = 19802, upload-time = "2024-05-30T13:40:14.661Z" }, ] -[[package]] -name = "win32-setctime" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, -] - [[package]] name = "wrapt" version = "1.17.2" diff --git a/www/.dockerignore b/www/.dockerignore new file mode 100644 index 00000000..c2d061c7 --- /dev/null +++ b/www/.dockerignore @@ -0,0 +1,14 @@ +.env +.env.* +.env.local +.env.development +.env.production +node_modules +.next +.git +.gitignore +*.md +.DS_Store +coverage +.pnpm-store +*.log \ No newline at end of file diff --git a/www/.env.example b/www/.env.example new file mode 100644 index 00000000..da46b513 --- /dev/null +++ b/www/.env.example @@ -0,0 +1,30 @@ +# Site Configuration +SITE_URL=http://localhost:3000 + +# Nextauth envs +# not used in app code but in lib code +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=your-nextauth-secret-here +# / Nextauth envs + +# Authentication (Authentik OAuth/OIDC) +AUTHENTIK_ISSUER=https://authentik.example.com/application/o/reflector +AUTHENTIK_REFRESH_TOKEN_URL=https://authentik.example.com/application/o/token/ +AUTHENTIK_CLIENT_ID=your-client-id-here +AUTHENTIK_CLIENT_SECRET=your-client-secret-here + +# Feature Flags +# FEATURE_REQUIRE_LOGIN=true +# FEATURE_PRIVACY=false +# FEATURE_BROWSE=true +# FEATURE_SEND_TO_ZULIP=true +# FEATURE_ROOMS=true + +# API URLs +API_URL=http://127.0.0.1:1250 +WEBSOCKET_URL=ws://127.0.0.1:1250 +AUTH_CALLBACK_URL=http://localhost:3000/auth-callback + +# Sentry +# SENTRY_DSN=https://your-dsn@sentry.io/project-id +# SENTRY_IGNORE_API_RESOLUTION_ERROR=1 \ No newline at end of file diff --git a/www/.gitignore b/www/.gitignore index c0ad8c1e..9acefbb2 100644 --- a/www/.gitignore +++ b/www/.gitignore @@ -40,7 +40,6 @@ next-env.d.ts # Sentry Auth Token .sentryclirc -config.ts # openapi logs openapi-ts-error-*.log diff --git a/www/.npmrc b/www/.npmrc new file mode 100644 index 00000000..052f58b5 --- /dev/null +++ b/www/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 #24hr in minutes \ No newline at end of file diff --git a/www/DOCKER_README.md b/www/DOCKER_README.md new file mode 100644 index 00000000..59d4c8ac --- /dev/null +++ b/www/DOCKER_README.md @@ -0,0 +1,81 @@ +# Docker Production Build Guide + +## Overview + +The Docker image builds without any environment variables and requires all configuration to be provided at runtime. + +## Environment Variables (ALL Runtime) + +### Required Runtime Variables + +```bash +API_URL # Backend API URL (e.g., https://api.example.com) +WEBSOCKET_URL # WebSocket URL (e.g., wss://api.example.com) +NEXTAUTH_URL # NextAuth base URL (e.g., https://app.example.com) +NEXTAUTH_SECRET # Random secret for NextAuth (generate with: openssl rand -base64 32) +KV_URL # Redis URL (e.g., redis://redis:6379) +``` + +### Optional Runtime Variables + +```bash +SITE_URL # Frontend URL (defaults to NEXTAUTH_URL) + +AUTHENTIK_ISSUER # OAuth issuer URL +AUTHENTIK_CLIENT_ID # OAuth client ID +AUTHENTIK_CLIENT_SECRET # OAuth client secret +AUTHENTIK_REFRESH_TOKEN_URL # OAuth token refresh URL + +FEATURE_REQUIRE_LOGIN=false # Require authentication +FEATURE_PRIVACY=true # Enable privacy features +FEATURE_BROWSE=true # Enable browsing features +FEATURE_SEND_TO_ZULIP=false # Enable Zulip integration +FEATURE_ROOMS=true # Enable rooms feature + +SENTRY_DSN # Sentry error tracking +AUTH_CALLBACK_URL # OAuth callback URL +``` + +## Building the Image + +### Option 1: Using Docker Compose + +1. Build the image (no environment variables needed): + +```bash +docker compose -f docker-compose.prod.yml build +``` + +2. Create a `.env` file with runtime variables + +3. Run with environment variables: + +```bash +docker compose -f docker-compose.prod.yml --env-file .env up -d +``` + +### Option 2: Using Docker CLI + +1. Build the image (no build args): + +```bash +docker build -t reflector-frontend:latest ./www +``` + +2. Run with environment variables: + +```bash +docker run -d \ + -p 3000:3000 \ + -e API_URL=https://api.example.com \ + -e WEBSOCKET_URL=wss://api.example.com \ + -e NEXTAUTH_URL=https://app.example.com \ + -e NEXTAUTH_SECRET=your-secret \ + -e KV_URL=redis://redis:6379 \ + -e AUTHENTIK_ISSUER=https://auth.example.com/application/o/reflector \ + -e AUTHENTIK_CLIENT_ID=your-client-id \ + -e AUTHENTIK_CLIENT_SECRET=your-client-secret \ + -e AUTHENTIK_REFRESH_TOKEN_URL=https://auth.example.com/application/o/token/ \ + -e FEATURE_REQUIRE_LOGIN=true \ + reflector-frontend:latest +``` diff --git a/www/Dockerfile b/www/Dockerfile index 68c23e33..65729046 100644 --- a/www/Dockerfile +++ b/www/Dockerfile @@ -24,7 +24,8 @@ COPY --link . . ENV NEXT_TELEMETRY_DISABLED 1 # If using npm comment out above and use below instead -RUN pnpm build +# next.js has the feature of excluding build step planned https://github.com/vercel/next.js/discussions/46544 +RUN pnpm build-production # RUN npm run build # Production image, copy all the files and run next @@ -51,6 +52,10 @@ USER nextjs EXPOSE 3000 ENV PORT 3000 -ENV HOSTNAME localhost +ENV HOSTNAME 0.0.0.0 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health \ + || exit 1 CMD ["node", "server.js"] diff --git a/www/app/(app)/AuthWrapper.tsx b/www/app/(app)/AuthWrapper.tsx new file mode 100644 index 00000000..57038b7b --- /dev/null +++ b/www/app/(app)/AuthWrapper.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Flex, Spinner } from "@chakra-ui/react"; +import { useAuth } from "../lib/AuthProvider"; +import { useLoginRequiredPages } from "../lib/useLoginRequiredPages"; + +export default function AuthWrapper({ + children, +}: { + children: React.ReactNode; +}) { + const auth = useAuth(); + const redirectPath = useLoginRequiredPages(); + const redirectHappens = !!redirectPath; + + if (auth.status === "loading" || redirectHappens) { + return ( + + + + ); + } + + return <>{children}; +} diff --git a/www/app/(app)/browse/_components/FilterSidebar.tsx b/www/app/(app)/browse/_components/FilterSidebar.tsx index b2abe481..6eef61b8 100644 --- a/www/app/(app)/browse/_components/FilterSidebar.tsx +++ b/www/app/(app)/browse/_components/FilterSidebar.tsx @@ -1,7 +1,10 @@ import React from "react"; import { Box, Stack, Link, Heading } from "@chakra-ui/react"; import NextLink from "next/link"; -import { Room, SourceKind } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type Room = components["schemas"]["Room"]; +type SourceKind = components["schemas"]["SourceKind"]; interface FilterSidebarProps { rooms: Room[]; @@ -72,7 +75,7 @@ export default function FilterSidebar({ key={room.id} as={NextLink} href="#" - onClick={() => onFilterChange("room", room.id)} + onClick={() => onFilterChange("room" as SourceKind, room.id)} color={ selectedSourceKind === "room" && selectedRoomId === room.id ? "blue.500" diff --git a/www/app/(app)/browse/_components/TranscriptCards.tsx b/www/app/(app)/browse/_components/TranscriptCards.tsx index b67e71e7..8dbc3568 100644 --- a/www/app/(app)/browse/_components/TranscriptCards.tsx +++ b/www/app/(app)/browse/_components/TranscriptCards.tsx @@ -18,7 +18,10 @@ import { highlightMatches, generateTextFragment, } from "../../../lib/textHighlight"; -import { SearchResult } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type SearchResult = components["schemas"]["SearchResult"]; +type SourceKind = components["schemas"]["SourceKind"]; interface TranscriptCardsProps { results: SearchResult[]; @@ -120,7 +123,7 @@ function TranscriptCard({ : "N/A"; const formattedDate = formatLocalDate(result.created_at); const source = - result.source_kind === "room" + result.source_kind === ("room" as SourceKind) ? result.room_name || result.room_id : result.source_kind; diff --git a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx index 0eebadc8..20164993 100644 --- a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx +++ b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx @@ -7,9 +7,10 @@ import { FaMicrophone, FaGear, } from "react-icons/fa6"; +import { TranscriptStatus } from "../../../lib/transcript"; interface TranscriptStatusIconProps { - status: string; + status: TranscriptStatus; } export default function TranscriptStatusIcon({ diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx index e7522e14..05d8d5da 100644 --- a/www/app/(app)/browse/page.tsx +++ b/www/app/(app)/browse/page.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Flex, Spinner, @@ -19,37 +19,33 @@ import { parseAsStringLiteral, } from "nuqs"; import { LuX } from "react-icons/lu"; -import { useSearchTranscripts } from "../transcripts/useSearchTranscripts"; -import useSessionUser from "../../lib/useSessionUser"; -import { Room, SourceKind, SearchResult, $SourceKind } from "../../api"; -import useApi from "../../lib/useApi"; -import { useError } from "../../(errors)/errorContext"; +import type { components } from "../../reflector-api"; + +type Room = components["schemas"]["Room"]; +type SourceKind = components["schemas"]["SourceKind"]; +type SearchResult = components["schemas"]["SearchResult"]; +import { + useRoomsList, + useTranscriptsSearch, + useTranscriptDelete, + useTranscriptProcess, +} from "../../lib/apiHooks"; import FilterSidebar from "./_components/FilterSidebar"; import Pagination, { FIRST_PAGE, PaginationPage, parsePaginationPage, totalPages as getTotalPages, + paginationPageTo0Based, } from "./_components/Pagination"; import TranscriptCards from "./_components/TranscriptCards"; import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; import { formatLocalDate } from "../../lib/time"; import { RECORD_A_MEETING_URL } from "../../api/urls"; +import { useUserName } from "../../lib/useUserName"; const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const; -const usePrefetchRooms = (setRooms: (rooms: Room[]) => void): void => { - const { setError } = useError(); - const api = useApi(); - useEffect(() => { - if (!api) return; - api - .v1RoomsList({ page: 1 }) - .then((rooms) => setRooms(rooms.items)) - .catch((err) => setError(err, "There was an error fetching the rooms")); - }, [api, setError]); -}; - const SearchForm: React.FC<{ setPage: (page: PaginationPage) => void; sourceKind: SourceKind | null; @@ -69,7 +65,6 @@ const SearchForm: React.FC<{ searchQuery, setSearchQuery, }) => { - // to keep the search input controllable + more fine grained control (urlSearchQuery is updated on submits) const [searchInputValue, setSearchInputValue] = useState(searchQuery || ""); const handleSearchQuerySubmit = async (d: FormData) => { await setSearchQuery((d.get(SEARCH_FORM_QUERY_INPUT_NAME) as string) || ""); @@ -163,7 +158,6 @@ const UnderSearchFormFilterIndicators: React.FC<{ p="1px" onClick={() => { setSourceKind(null); - // TODO questionable setRoomId(null); }} _hover={{ bg: "blue.200" }} @@ -209,7 +203,11 @@ export default function TranscriptBrowser() { const [urlSourceKind, setUrlSourceKind] = useQueryState( "source", - parseAsStringLiteral($SourceKind.enum).withOptions({ + parseAsStringLiteral([ + "room", + "live", + "file", + ] as const satisfies SourceKind[]).withOptions({ shallow: false, }), ); @@ -229,46 +227,57 @@ export default function TranscriptBrowser() { useEffect(() => { const maybePage = parsePaginationPage(urlPage); if ("error" in maybePage) { - setPage(FIRST_PAGE).then(() => { - /*may be called n times we dont care*/ - }); + setPage(FIRST_PAGE).then(() => {}); return; } _setSafePage(maybePage.value); }, [urlPage]); - const [rooms, setRooms] = useState([]); - const pageSize = 20; - const { - results, - totalCount: totalResults, - isLoading, - reload, - } = useSearchTranscripts( - urlSearchQuery, - { - roomIds: urlRoomId ? [urlRoomId] : null, - sourceKind: urlSourceKind, - }, - { - pageSize, - page, - }, + + // must be json-able + const searchFilters = useMemo( + () => ({ + q: urlSearchQuery, + extras: { + room_id: urlRoomId || undefined, + source_kind: urlSourceKind || undefined, + }, + }), + [urlSearchQuery, urlRoomId, urlSourceKind], ); + const { + data: searchData, + isLoading: searchLoading, + refetch: reloadSearch, + } = useTranscriptsSearch(searchFilters.q, { + limit: pageSize, + offset: paginationPageTo0Based(page) * pageSize, + ...searchFilters.extras, + }); + + const results = searchData?.results || []; + const totalResults = searchData?.total || 0; + + // Fetch rooms + const { data: roomsData } = useRoomsList(1); + const rooms = roomsData?.items || []; + const totalPages = getTotalPages(totalResults, pageSize); - const userName = useSessionUser().name; + // reset pagination when search results change (detected by total change; good enough approximation) + useEffect(() => { + // operation is idempotent + setPage(FIRST_PAGE).then(() => {}); + }, [JSON.stringify(searchFilters)]); + + const userName = useUserName(); const [deletionLoading, setDeletionLoading] = useState(false); - const api = useApi(); - const { setError } = useError(); const cancelRef = React.useRef(null); const [transcriptToDeleteId, setTranscriptToDeleteId] = React.useState(); - usePrefetchRooms(setRooms); - const handleFilterTranscripts = ( sourceKind: SourceKind | null, roomId: string, @@ -280,44 +289,37 @@ export default function TranscriptBrowser() { const onCloseDeletion = () => setTranscriptToDeleteId(undefined); + const deleteTranscript = useTranscriptDelete(); + const processTranscript = useTranscriptProcess(); + const confirmDeleteTranscript = (transcriptId: string) => { - if (!api || deletionLoading) return; + if (deletionLoading) return; setDeletionLoading(true); - api - .v1TranscriptDelete({ transcriptId }) - .then(() => { - setDeletionLoading(false); - onCloseDeletion(); - reload(); - }) - .catch((err) => { - setDeletionLoading(false); - setError(err, "There was an error deleting the transcript"); - }); + deleteTranscript.mutate( + { + params: { + path: { transcript_id: transcriptId }, + }, + }, + { + onSuccess: () => { + setDeletionLoading(false); + onCloseDeletion(); + reloadSearch(); + }, + onError: () => { + setDeletionLoading(false); + }, + }, + ); }; const handleProcessTranscript = (transcriptId: string) => { - if (!api) { - console.error("API not available on handleProcessTranscript"); - return; - } - api - .v1TranscriptProcess({ transcriptId }) - .then((result) => { - const status = - result && typeof result === "object" && "status" in result - ? (result as { status: string }).status - : undefined; - if (status === "already running") { - setError( - new Error("Processing is already running, please wait"), - "Processing is already running, please wait", - ); - } - }) - .catch((err) => { - setError(err, "There was an error processing the transcript"); - }); + processTranscript.mutate({ + params: { + path: { transcript_id: transcriptId }, + }, + }); }; const transcriptToDelete = results?.find( @@ -332,7 +334,7 @@ export default function TranscriptBrowser() { ? transcriptToDelete.room_name || transcriptToDelete.room_id : transcriptToDelete?.source_kind; - if (isLoading && results.length === 0) { + if (searchLoading && results.length === 0) { return ( {userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "} - {(isLoading || deletionLoading) && } + {(searchLoading || deletionLoading) && } @@ -403,12 +405,12 @@ export default function TranscriptBrowser() { - {!isLoading && results.length === 0 && ( + {!searchLoading && results.length === 0 && ( )} diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx index 5760e19d..7d9f1c84 100644 --- a/www/app/(app)/layout.tsx +++ b/www/app/(app)/layout.tsx @@ -1,10 +1,9 @@ import { Container, Flex, Link } from "@chakra-ui/react"; -import { getConfig } from "../lib/edgeConfig"; +import { featureEnabled } from "../lib/features"; import NextLink from "next/link"; import Image from "next/image"; -import About from "../(aboutAndPrivacy)/about"; -import Privacy from "../(aboutAndPrivacy)/privacy"; import UserInfo from "../(auth)/userInfo"; +import AuthWrapper from "./AuthWrapper"; import { RECORD_A_MEETING_URL } from "../api/urls"; export default async function AppLayout({ @@ -12,8 +11,6 @@ export default async function AppLayout({ }: { children: React.ReactNode; }) { - const config = await getConfig(); - const { requireLogin, privacy, browse, rooms } = config.features; return ( Create - {browse ? ( + {featureEnabled("browse") ? ( <>  ·  @@ -69,7 +66,7 @@ export default async function AppLayout({ ) : ( <> )} - {rooms ? ( + {featureEnabled("rooms") ? ( <>  ·  @@ -79,8 +76,16 @@ export default async function AppLayout({ ) : ( <> )} - {requireLogin ? ( + {featureEnabled("requireLogin") ? ( <> +  ·  + + Settings +  ·  @@ -90,7 +95,7 @@ export default async function AppLayout({ - {children} + {children} ); } diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx new file mode 100644 index 00000000..58f5db98 --- /dev/null +++ b/www/app/(app)/rooms/_components/ICSSettings.tsx @@ -0,0 +1,351 @@ +import { + VStack, + HStack, + Field, + Input, + Select, + Checkbox, + Button, + Text, + Badge, + createListCollection, + Spinner, + Box, + IconButton, +} from "@chakra-ui/react"; +import { useState, useEffect, useRef } from "react"; +import { LuRefreshCw, LuCopy, LuCheck } from "react-icons/lu"; +import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa"; +import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks"; +import { toaster } from "../../../components/ui/toaster"; +import { roomAbsoluteUrl } from "../../../lib/routesClient"; +import { + assertExists, + assertExistsAndNonEmptyString, + NonEmptyString, + parseNonEmptyString, +} from "../../../lib/utils"; + +interface ICSSettingsProps { + roomName: NonEmptyString | null; + icsUrl?: string; + icsEnabled?: boolean; + icsFetchInterval?: number; + icsLastSync?: string; + icsLastEtag?: string; + onChange: (settings: Partial) => void; + isOwner?: boolean; + isEditing?: boolean; +} + +export interface ICSSettingsData { + ics_url: string; + ics_enabled: boolean; + ics_fetch_interval: number; +} + +const fetchIntervalOptions = [ + { label: "1 minute", value: "1" }, + { label: "5 minutes", value: "5" }, + { label: "10 minutes", value: "10" }, + { label: "30 minutes", value: "30" }, + { label: "1 hour", value: "60" }, +]; + +export default function ICSSettings({ + roomName, + icsUrl = "", + icsEnabled = false, + icsFetchInterval = 5, + icsLastSync, + icsLastEtag, + onChange, + isOwner = true, + isEditing = false, +}: ICSSettingsProps) { + const [syncStatus, setSyncStatus] = useState< + "idle" | "syncing" | "success" | "error" + >("idle"); + const [syncMessage, setSyncMessage] = useState(""); + const [syncResult, setSyncResult] = useState<{ + eventsFound: number; + totalEvents: number; + eventsCreated: number; + eventsUpdated: number; + } | null>(null); + const [justCopied, setJustCopied] = useState(false); + const roomUrlInputRef = useRef(null); + + const syncMutation = useRoomIcsSync(); + + const fetchIntervalCollection = createListCollection({ + items: fetchIntervalOptions, + }); + + const handleCopyRoomUrl = async () => { + try { + await navigator.clipboard.writeText( + roomAbsoluteUrl(assertExists(roomName)), + ); + setJustCopied(true); + + toaster + .create({ + placement: "top", + duration: 3000, + render: ({ dismiss }) => ( + + + Room URL copied to clipboard! + + ), + }) + .then(() => {}); + + setTimeout(() => { + setJustCopied(false); + }, 2000); + } catch (err) { + console.error("Failed to copy room url:", err); + } + }; + + const handleRoomUrlClick = () => { + if (roomUrlInputRef.current) { + roomUrlInputRef.current.select(); + handleCopyRoomUrl().then(() => {}); + } + }; + + // Clear sync results when dialog closes + useEffect(() => { + if (!isEditing) { + setSyncStatus("idle"); + setSyncResult(null); + setSyncMessage(""); + } + }, [isEditing]); + + const handleForceSync = async () => { + if (!roomName || !isEditing) return; + + // Clear previous results + setSyncStatus("syncing"); + setSyncResult(null); + setSyncMessage(""); + + try { + const result = await syncMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + }); + + if (result.status === "success" || result.status === "unchanged") { + setSyncStatus("success"); + setSyncResult({ + eventsFound: result.events_found || 0, + totalEvents: result.total_events || 0, + eventsCreated: result.events_created || 0, + eventsUpdated: result.events_updated || 0, + }); + } else { + setSyncStatus("error"); + setSyncMessage(result.error || "Sync failed"); + } + } catch (err: any) { + setSyncStatus("error"); + setSyncMessage(err.body?.detail || "Failed to force sync calendar"); + } + }; + + if (!isOwner) { + return null; // ICS settings only visible to room owner + } + + return ( + + + onChange({ ics_enabled: !!e.checked })} + > + + + + + Enable ICS calendar sync + + + + {icsEnabled && ( + <> + + Room URL + + To enable Reflector to recognize your calendar events as meetings, + add this URL as the location in your calendar events + + {roomName ? ( + + + + + {justCopied ? : } + + + + ) : null} + + + + ICS Calendar URL + onChange({ ics_url: e.target.value })} + /> + + Enter the ICS URL from Google Calendar, Outlook, or other calendar + services + + + + + Sync Interval + { + const value = parseInt(details.value[0]); + onChange({ ics_fetch_interval: value }); + }} + > + + + + + {fetchIntervalOptions.map((option) => ( + + {option.label} + + ))} + + + + How often to check for calendar updates + + + + {icsUrl && isEditing && roomName && ( + + + + )} + + {syncResult && syncStatus === "success" && ( + + + + Sync completed + + + {syncResult.totalEvents} events downloaded,{" "} + {syncResult.eventsFound} match this room + + {(syncResult.eventsCreated > 0 || + syncResult.eventsUpdated > 0) && ( + + {syncResult.eventsCreated} created,{" "} + {syncResult.eventsUpdated} updated + + )} + + + )} + + {syncMessage && ( + + + {syncMessage} + + + )} + + {icsLastSync && ( + + + + Last sync: {new Date(icsLastSync).toLocaleString()} + + {icsLastEtag && ( + + ETag: {icsLastEtag.slice(0, 8)}... + + )} + + )} + + )} + + ); +} diff --git a/www/app/(app)/rooms/_components/RoomCards.tsx b/www/app/(app)/rooms/_components/RoomCards.tsx index 15079a7a..8b22ad72 100644 --- a/www/app/(app)/rooms/_components/RoomCards.tsx +++ b/www/app/(app)/rooms/_components/RoomCards.tsx @@ -12,7 +12,9 @@ import { HStack, } from "@chakra-ui/react"; import { LuLink } from "react-icons/lu"; -import { Room } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type Room = components["schemas"]["Room"]; import { RoomActionsMenu } from "./RoomActionsMenu"; interface RoomCardsProps { diff --git a/www/app/(app)/rooms/_components/RoomList.tsx b/www/app/(app)/rooms/_components/RoomList.tsx index 17cd5fc5..8cd83277 100644 --- a/www/app/(app)/rooms/_components/RoomList.tsx +++ b/www/app/(app)/rooms/_components/RoomList.tsx @@ -1,13 +1,16 @@ import { Box, Heading, Text, VStack } from "@chakra-ui/react"; -import { Room } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type Room = components["schemas"]["Room"]; import { RoomTable } from "./RoomTable"; import { RoomCards } from "./RoomCards"; +import { NonEmptyString } from "../../../lib/utils"; interface RoomListProps { title: string; rooms: Room[]; linkCopied: string; - onCopyUrl: (roomName: string) => void; + onCopyUrl: (roomName: NonEmptyString) => void; onEdit: (roomId: string, roomData: any) => void; onDelete: (roomId: string) => void; emptyMessage?: string; diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx index 092fccdc..4b31d6b3 100644 --- a/www/app/(app)/rooms/_components/RoomTable.tsx +++ b/www/app/(app)/rooms/_components/RoomTable.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { Box, Table, @@ -7,15 +7,58 @@ import { IconButton, Text, Spinner, + Badge, + VStack, + Icon, } from "@chakra-ui/react"; -import { LuLink } from "react-icons/lu"; -import { Room } from "../../../api"; +import { LuLink, LuRefreshCw } from "react-icons/lu"; +import { FaCalendarAlt } from "react-icons/fa"; +import type { components } from "../../../reflector-api"; +import { + useRoomActiveMeetings, + useRoomUpcomingMeetings, + useRoomIcsSync, +} from "../../../lib/apiHooks"; + +type Room = components["schemas"]["Room"]; +type Meeting = components["schemas"]["Meeting"]; +type CalendarEventResponse = components["schemas"]["CalendarEventResponse"]; import { RoomActionsMenu } from "./RoomActionsMenu"; +import { MEETING_DEFAULT_TIME_MINUTES } from "../../../[roomName]/[meetingId]/constants"; +import { NonEmptyString, parseNonEmptyString } from "../../../lib/utils"; + +// Custom icon component that combines calendar and refresh icons +const CalendarSyncIcon = () => ( + + + + + + +); interface RoomTableProps { rooms: Room[]; linkCopied: string; - onCopyUrl: (roomName: string) => void; + onCopyUrl: (roomName: NonEmptyString) => void; onEdit: (roomId: string, roomData: any) => void; onDelete: (roomId: string) => void; loading?: boolean; @@ -61,6 +104,71 @@ const getZulipDisplay = ( return "Enabled"; }; +function MeetingStatus({ roomName }: { roomName: string }) { + const activeMeetingsQuery = useRoomActiveMeetings(roomName); + const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName); + + const activeMeetings = activeMeetingsQuery.data || []; + const upcomingMeetings = upcomingMeetingsQuery.data || []; + + if (activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading) { + return ; + } + + if (activeMeetings.length > 0) { + const meeting = activeMeetings[0]; + const title = String( + meeting.calendar_metadata?.["title"] || "Active Meeting", + ); + return ( + + + {title} + + + {meeting.num_clients} participants + + + ); + } + + if (upcomingMeetings.length > 0) { + const event = upcomingMeetings[0]; + const startTime = new Date(event.start_time); + const now = new Date(); + const diffMinutes = Math.floor( + (startTime.getTime() - now.getTime()) / 60000, + ); + + return ( + + + {diffMinutes < MEETING_DEFAULT_TIME_MINUTES + ? `In ${diffMinutes}m` + : "Upcoming"} + + + {event.title || "Scheduled Meeting"} + + + {startTime.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "numeric", + })} + + + ); + } + + return ( + + No meetings + + ); +} + export function RoomTable({ rooms, linkCopied, @@ -69,6 +177,30 @@ export function RoomTable({ onDelete, loading, }: RoomTableProps) { + const [syncingRooms, setSyncingRooms] = useState>( + new Set(), + ); + const syncMutation = useRoomIcsSync(); + + const handleForceSync = async (roomName: NonEmptyString) => { + setSyncingRooms((prev) => new Set(prev).add(roomName)); + try { + await syncMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + }); + } catch (err) { + console.error("Failed to sync calendar:", err); + } finally { + setSyncingRooms((prev) => { + const next = new Set(prev); + next.delete(roomName); + return next; + }); + } + }; + return ( {loading && ( @@ -95,13 +227,16 @@ export function RoomTable({ Room Name - - Zulip - - - Room Size + + Current Meeting + Zulip + + + Room Size + + Recording {room.name} + + + {getZulipDisplay( room.zulip_auto_post, @@ -131,7 +269,42 @@ export function RoomTable({ )} - + + {room.ics_enabled && ( + + handleForceSync( + parseNonEmptyString( + room.name, + true, + "panic! room.name is required", + ), + ) + } + size="sm" + variant="ghost" + disabled={syncingRooms.has( + parseNonEmptyString( + room.name, + true, + "panic! room.name is required", + ), + )} + > + {syncingRooms.has( + parseNonEmptyString( + room.name, + true, + "panic! room.name is required", + ), + ) ? ( + + ) : ( + + )} + + )} {linkCopied === room.name ? ( Copied! @@ -139,7 +312,15 @@ export function RoomTable({ ) : ( onCopyUrl(room.name)} + onClick={() => + onCopyUrl( + parseNonEmptyString( + room.name, + true, + "panic! room.name is required", + ), + ) + } size="sm" variant="ghost" > diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 305087f9..a7a68d2f 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -11,15 +11,35 @@ import { Input, Select, Spinner, + IconButton, createListCollection, useDisclosure, + Tabs, } from "@chakra-ui/react"; -import { useEffect, useState } from "react"; -import useApi from "../../lib/useApi"; +import { useEffect, useMemo, useState } from "react"; +import { LuEye, LuEyeOff } from "react-icons/lu"; import useRoomList from "./useRoomList"; -import { ApiError, Room } from "../../api"; +import type { components } from "../../reflector-api"; +import { + useRoomCreate, + useRoomUpdate, + useRoomDelete, + useZulipStreams, + useZulipTopics, + useRoomGet, + useRoomTestWebhook, +} from "../../lib/apiHooks"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; +import { + assertExists, + NonEmptyString, + parseNonEmptyString, +} from "../../lib/utils"; +import ICSSettings from "./_components/ICSSettings"; +import { roomAbsoluteUrl } from "../../lib/routesClient"; + +type Room = components["schemas"]["Room"]; interface SelectOption { label: string; @@ -27,6 +47,8 @@ interface SelectOption { } const RESERVED_PATHS = ["browse", "rooms", "transcripts"]; +const SUCCESS_EMOJI = "✅"; +const ERROR_EMOJI = "❌"; const roomModeOptions: SelectOption[] = [ { label: "2-4 people", value: "normal" }, @@ -55,6 +77,11 @@ const roomInitialState = { recordingType: "cloud", recordingTrigger: "automatic-2nd-participant", isShared: false, + webhookUrl: "", + webhookSecret: "", + icsUrl: "", + icsEnabled: false, + icsFetchInterval: 5, }; export default function RoomsList() { @@ -72,61 +99,80 @@ export default function RoomsList() { const recordingTypeCollection = createListCollection({ items: recordingTypeOptions, }); - const [room, setRoom] = useState(roomInitialState); + const [roomInput, setRoomInput] = useState( + null, + ); const [isEditing, setIsEditing] = useState(false); - const [editRoomId, setEditRoomId] = useState(""); - const api = useApi(); - // TODO seems to be no setPage calls - const [page, setPage] = useState(1); - const { loading, response, refetch } = useRoomList(PaginationPage(page)); - const [streams, setStreams] = useState([]); - const [topics, setTopics] = useState([]); + const [editRoomId, setEditRoomId] = useState(null); + const { + loading, + response, + refetch, + error: roomListError, + } = useRoomList(PaginationPage(1)); const [nameError, setNameError] = useState(""); const [linkCopied, setLinkCopied] = useState(""); - interface Stream { - stream_id: number; - name: string; - } + const [selectedStreamId, setSelectedStreamId] = useState(null); + const [testingWebhook, setTestingWebhook] = useState(false); + const [webhookTestResult, setWebhookTestResult] = useState( + null, + ); + const [showWebhookSecret, setShowWebhookSecret] = useState(false); - interface Topic { - name: string; - } + const createRoomMutation = useRoomCreate(); + const updateRoomMutation = useRoomUpdate(); + const deleteRoomMutation = useRoomDelete(); + const { data: streams = [] } = useZulipStreams(); + const { data: topics = [] } = useZulipTopics(selectedStreamId); + const { + data: detailedEditedRoom, + isLoading: isDetailedEditedRoomLoading, + error: detailedEditedRoomError, + } = useRoomGet(editRoomId); + + const error = roomListError || detailedEditedRoomError; + + // room being edited, as fetched from the server + const editedRoom: typeof roomInitialState | null = useMemo( + () => + detailedEditedRoom + ? { + name: detailedEditedRoom.name, + zulipAutoPost: detailedEditedRoom.zulip_auto_post, + zulipStream: detailedEditedRoom.zulip_stream, + zulipTopic: detailedEditedRoom.zulip_topic, + isLocked: detailedEditedRoom.is_locked, + roomMode: detailedEditedRoom.room_mode, + recordingType: detailedEditedRoom.recording_type, + recordingTrigger: detailedEditedRoom.recording_trigger, + isShared: detailedEditedRoom.is_shared, + webhookUrl: detailedEditedRoom.webhook_url || "", + webhookSecret: detailedEditedRoom.webhook_secret || "", + icsUrl: detailedEditedRoom.ics_url || "", + icsEnabled: detailedEditedRoom.ics_enabled || false, + icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5, + } + : null, + [detailedEditedRoom], + ); + + // a room input value or a last api room state + const room = roomInput || editedRoom || roomInitialState; + + const roomTestWebhookMutation = useRoomTestWebhook(); + + // Update selected stream ID when zulip stream changes useEffect(() => { - const fetchZulipStreams = async () => { - if (!api) return; - - try { - const response = await api.v1ZulipGetStreams(); - setStreams(response); - } catch (error) { - console.error("Error fetching Zulip streams:", error); + if (room.zulipStream && streams.length > 0) { + const selectedStream = streams.find((s) => s.name === room.zulipStream); + if (selectedStream !== undefined) { + setSelectedStreamId(selectedStream.stream_id); } - }; - - if (room.zulipAutoPost) { - fetchZulipStreams(); + } else { + setSelectedStreamId(null); } - }, [room.zulipAutoPost, !api]); - - useEffect(() => { - const fetchZulipTopics = async () => { - if (!api || !room.zulipStream) return; - try { - const selectedStream = streams.find((s) => s.name === room.zulipStream); - if (selectedStream) { - const response = await api.v1ZulipGetTopics({ - streamId: selectedStream.stream_id, - }); - setTopics(response); - } - } catch (error) { - console.error("Error fetching Zulip topics:", error); - } - }; - - fetchZulipTopics(); - }, [room.zulipStream, streams, api]); + }, [room.zulipStream, streams]); const streamOptions: SelectOption[] = streams.map((stream) => { return { label: stream.name, value: stream.name }; @@ -145,14 +191,83 @@ export default function RoomsList() { items: topicOptions, }); - const handleCopyUrl = (roomName: string) => { - const roomUrl = `${window.location.origin}/${roomName}`; - navigator.clipboard.writeText(roomUrl); - setLinkCopied(roomName); + const handleCopyUrl = (roomName: NonEmptyString) => { + navigator.clipboard.writeText(roomAbsoluteUrl(roomName)).then(() => { + setLinkCopied(roomName); + setTimeout(() => { + setLinkCopied(""); + }, 2000); + }); + }; + const handleCloseDialog = () => { + setShowWebhookSecret(false); + setWebhookTestResult(null); + setEditRoomId(null); + onClose(); + }; + + const handleTestWebhook = async () => { + if (!room.webhookUrl) { + setWebhookTestResult("Please enter a webhook URL first"); + return; + } + if (!editRoomId) { + console.error("No room ID to test webhook"); + return; + } + + setTestingWebhook(true); + setWebhookTestResult(null); + + try { + const response = await roomTestWebhookMutation.mutateAsync({ + params: { + path: { + room_id: editRoomId, + }, + }, + }); + + if (response.success) { + setWebhookTestResult( + `${SUCCESS_EMOJI} Webhook test successful! Status: ${response.status_code}`, + ); + } else { + let errorMsg = `${ERROR_EMOJI} Webhook test failed`; + errorMsg += ` (Status: ${response.status_code})`; + if (response.error) { + errorMsg += `: ${response.error}`; + } else if (response.response_preview) { + // Try to parse and extract meaningful error from response + // Specific to N8N at the moment, as there is no specification for that + // We could just display as is, but decided here to dig a little bit more. + try { + const preview = JSON.parse(response.response_preview); + if (preview.message) { + errorMsg += `: ${preview.message}`; + } + } catch { + // If not JSON, just show the preview text (truncated) + const previewText = response.response_preview.substring(0, 150); + errorMsg += `: ${previewText}`; + } + } else if (response?.message) { + errorMsg += `: ${response.message}`; + } + setWebhookTestResult(errorMsg); + } + } catch (error) { + console.error("Error testing webhook:", error); + setWebhookTestResult("❌ Failed to test webhook. Please check your URL."); + } finally { + setTestingWebhook(false); + } + + // Clear result after 5 seconds setTimeout(() => { - setLinkCopied(""); - }, 2000); + setWebhookTestResult(null); + }, 5000); }; const handleSaveRoom = async () => { @@ -172,30 +287,37 @@ export default function RoomsList() { recording_type: room.recordingType, recording_trigger: room.recordingTrigger, is_shared: room.isShared, + webhook_url: room.webhookUrl, + webhook_secret: room.webhookSecret, + ics_url: room.icsUrl, + ics_enabled: room.icsEnabled, + ics_fetch_interval: room.icsFetchInterval, }; if (isEditing) { - await api?.v1RoomsUpdate({ - roomId: editRoomId, - requestBody: roomData, + await updateRoomMutation.mutateAsync({ + params: { + path: { room_id: assertExists(editRoomId) }, + }, + body: roomData, }); } else { - await api?.v1RoomsCreate({ - requestBody: roomData, + await createRoomMutation.mutateAsync({ + body: roomData, }); } - setRoom(roomInitialState); + setRoomInput(null); setIsEditing(false); - setEditRoomId(""); + setEditRoomId(null); setNameError(""); refetch(); onClose(); - } catch (err) { + handleCloseDialog(); + } catch (err: any) { if ( - err instanceof ApiError && - err.status === 400 && - (err.body as any).detail == "Room name is not unique" + err?.status === 400 && + err?.body?.detail == "Room name is not unique" ) { setNameError( "This room name is already taken. Please choose a different name.", @@ -206,8 +328,12 @@ export default function RoomsList() { } }; - const handleEditRoom = (roomId, roomData) => { - setRoom({ + const handleEditRoom = async (roomId: string, roomData) => { + // Reset states + setShowWebhookSecret(false); + setWebhookTestResult(null); + + setRoomInput({ name: roomData.name, zulipAutoPost: roomData.zulip_auto_post, zulipStream: roomData.zulip_stream, @@ -217,6 +343,11 @@ export default function RoomsList() { recordingType: roomData.recording_type, recordingTrigger: roomData.recording_trigger, isShared: roomData.is_shared, + webhookUrl: roomData.webhook_url || "", + webhookSecret: roomData.webhook_secret || "", + icsUrl: roomData.ics_url || "", + icsEnabled: roomData.ics_enabled || false, + icsFetchInterval: roomData.ics_fetch_interval || 5, }); setEditRoomId(roomId); setIsEditing(true); @@ -226,8 +357,10 @@ export default function RoomsList() { const handleDeleteRoom = async (roomId: string) => { try { - await api?.v1RoomsDelete({ - roomId, + await deleteRoomMutation.mutateAsync({ + params: { + path: { room_id: roomId }, + }, }); refetch(); } catch (err) { @@ -244,7 +377,7 @@ export default function RoomsList() { .toLowerCase(); setNameError(""); } - setRoom({ + setRoomInput({ ...room, [name]: type === "checkbox" ? checked : value, }); @@ -267,6 +400,9 @@ export default function RoomsList() { ); + if (roomListError) + return
{`${roomListError.name}: ${roomListError.message}`}
; + return ( { setIsEditing(false); - setRoom(roomInitialState); + setRoomInput(null); setNameError(""); + setShowWebhookSecret(false); + setWebhookTestResult(null); onOpen(); }} > @@ -296,7 +434,7 @@ export default function RoomsList() { (e.open ? onOpen() : onClose())} + onOpenChange={(e) => (e.open ? onOpen() : handleCloseDialog())} size="lg" > @@ -311,258 +449,434 @@ export default function RoomsList() { - - Room name - - - No spaces or special characters allowed - - {nameError && {nameError}} - +
{ + e.preventDefault(); + handleSaveRoom(); + }} + > + + + General + Calendar + Share + WebHook + - - { - const syntheticEvent = { - target: { - name: "isLocked", - type: "checkbox", - checked: e.checked, - }, - }; - handleRoomChange(syntheticEvent); - }} - > - - - - - Locked room - - - - Room size - - setRoom({ ...room, roomMode: e.value[0] }) - } - collection={roomModeCollection} - > - - - - - - - - - - - - {roomModeOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - Recording type - - setRoom({ - ...room, - recordingType: e.value[0], - recordingTrigger: - e.value[0] !== "cloud" ? "none" : room.recordingTrigger, - }) - } - collection={recordingTypeCollection} - > - - - - - - - - - - - - {recordingTypeOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - Cloud recording start trigger - - setRoom({ ...room, recordingTrigger: e.value[0] }) - } - collection={recordingTriggerCollection} - disabled={room.recordingType !== "cloud"} - > - - - - - - - - - - - - {recordingTriggerOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - { - const syntheticEvent = { - target: { - name: "zulipAutoPost", - type: "checkbox", - checked: e.checked, - }, - }; - handleRoomChange(syntheticEvent); - }} - > - - - - - - Automatically post transcription to Zulip - - - - - Zulip stream - - setRoom({ - ...room, - zulipStream: e.value[0], - zulipTopic: "", - }) - } - collection={streamCollection} - disabled={!room.zulipAutoPost} - > - - - - - - - - - - - - {streamOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - Zulip topic - - setRoom({ ...room, zulipTopic: e.value[0] }) - } - collection={topicCollection} - disabled={!room.zulipAutoPost} - > - - - - - - - - - - - - {topicOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - { - const syntheticEvent = { - target: { - name: "isShared", - type: "checkbox", - checked: e.checked, - }, - }; - handleRoomChange(syntheticEvent); - }} - > - - - - - Shared room - - + + + Room name + + + No spaces or special characters allowed + + {nameError && ( + {nameError} + )} + + + + { + const syntheticEvent = { + target: { + name: "isLocked", + type: "checkbox", + checked: e.checked, + }, + }; + handleRoomChange(syntheticEvent); + }} + > + + + + + Locked room + + + + Room size + + setRoomInput({ ...room, roomMode: e.value[0] }) + } + collection={roomModeCollection} + > + + + + + + + + + + + + {roomModeOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + Recording type + + setRoomInput({ + ...room, + recordingType: e.value[0], + recordingTrigger: + e.value[0] !== "cloud" + ? "none" + : room.recordingTrigger, + }) + } + collection={recordingTypeCollection} + > + + + + + + + + + + + + {recordingTypeOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + Cloud recording start trigger + + setRoomInput({ + ...room, + recordingTrigger: e.value[0], + }) + } + collection={recordingTriggerCollection} + disabled={room.recordingType !== "cloud"} + > + + + + + + + + + + + + {recordingTriggerOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + + { + const syntheticEvent = { + target: { + name: "isShared", + type: "checkbox", + checked: e.checked, + }, + }; + handleRoomChange(syntheticEvent); + }} + > + + + + + Shared room + + + + + + + { + const syntheticEvent = { + target: { + name: "zulipAutoPost", + type: "checkbox", + checked: e.checked, + }, + }; + handleRoomChange(syntheticEvent); + }} + > + + + + + + Automatically post transcription to Zulip + + + + + Zulip stream + + setRoomInput({ + ...room, + zulipStream: e.value[0], + zulipTopic: "", + }) + } + collection={streamCollection} + disabled={!room.zulipAutoPost} + > + + + + + + + + + + + + {streamOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + Zulip topic + + setRoomInput({ ...room, zulipTopic: e.value[0] }) + } + collection={topicCollection} + disabled={!room.zulipAutoPost} + > + + + + + + + + + + + + {topicOptions.map((option) => ( + + {option.label} + + + ))} + + + + + + + + + Webhook URL + + + Optional: URL to receive notifications when transcripts + are ready + + + + {room.webhookUrl && ( + <> + + Webhook Secret + + + {isEditing && room.webhookSecret && ( + + setShowWebhookSecret(!showWebhookSecret) + } + > + {showWebhookSecret ? : } + + )} + + + Used for HMAC signature verification (auto-generated + if left empty) + + + + {isEditing && ( + <> + + + {webhookTestResult && ( +
+ {webhookTestResult} +
+ )} +
+ + )} + + )} +
+ + + + { + setRoomInput({ + ...room, + icsUrl: + settings.ics_url !== undefined + ? settings.ics_url + : room.icsUrl, + icsEnabled: + settings.ics_enabled !== undefined + ? settings.ics_enabled + : room.icsEnabled, + icsFetchInterval: + settings.ics_fetch_interval !== undefined + ? settings.ics_fetch_interval + : room.icsFetchInterval, + }); + }} + isOwner={true} + isEditing={isEditing} + /> + + +
+
- +
+ + Make sure to copy your API key now. You won't be able to see it + again! + + + + {createdKey.key} + + handleCopyKey(createdKey.key)} + > + + + +
+ )} + + {/* Create new key */} + + + Create New API Key + + {!isCreating ? ( + + ) : ( + + + Name (optional) + setNewKeyName(e.target.value)} + /> + + + + + + + )} + + + {/* List of API keys */} + + + Your API Keys + + {isLoading ? ( + Loading... + ) : !apiKeys || apiKeys.length === 0 ? ( + + No API keys yet. Create one to get started. + + ) : ( + + + + Name + Created + Actions + + + + {apiKeys.map((key) => ( + + + {key.name || Unnamed} + + {formatDate(key.created_at)} + + handleDeleteRequest(key.id)} + loading={ + deleteKeyMutation.isPending && + deleteKeyMutation.variables?.params?.path?.key_id === + key.id + } + > + + + + + ))} + + + )} + + + {/* Delete confirmation dialog */} + { + if (!e.open) setKeyToDelete(null); + }} + initialFocusEl={() => cancelRef.current} + > + + + + + Delete API Key + + + + Are you sure you want to delete this API key? This action cannot + be undone. + + + + + + + + + + + ); +} diff --git a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx index 1f5d1588..fdf3db41 100644 --- a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx @@ -3,8 +3,10 @@ import ScrollToBottom from "../../scrollToBottom"; import { Topic } from "../../webSocketTypes"; import useParticipants from "../../useParticipants"; import { Box, Flex, Text, Accordion } from "@chakra-ui/react"; -import { featureEnabled } from "../../../../domainContext"; import { TopicItem } from "./TopicItem"; +import { TranscriptStatus } from "../../../../lib/transcript"; + +import { featureEnabled } from "../../../../lib/features"; type TopicListProps = { topics: Topic[]; @@ -14,7 +16,7 @@ type TopicListProps = { ]; autoscroll: boolean; transcriptId: string; - status: string; + status: TranscriptStatus | null; currentTranscriptText: any; }; diff --git a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx index 9eff7b60..c4d5a9fc 100644 --- a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx @@ -1,30 +1,35 @@ "use client"; -import { useState } from "react"; +import { useState, use } from "react"; import TopicHeader from "./topicHeader"; import TopicWords from "./topicWords"; import TopicPlayer from "./topicPlayer"; import useParticipants from "../../useParticipants"; import useTopicWithWords from "../../useTopicWithWords"; import ParticipantList from "./participantList"; -import { GetTranscriptTopic } from "../../../../api"; +import type { components } from "../../../../reflector-api"; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; import { SelectedText, selectedTextIsTimeSlice } from "./types"; -import useApi from "../../../../lib/useApi"; -import useTranscript from "../../useTranscript"; +import { + useTranscriptGet, + useTranscriptUpdate, +} from "../../../../lib/apiHooks"; import { useError } from "../../../../(errors)/errorContext"; import { useRouter } from "next/navigation"; import { Box, Grid } from "@chakra-ui/react"; export type TranscriptCorrect = { - params: { + params: Promise<{ transcriptId: string; - }; + }>; }; -export default function TranscriptCorrect({ - params: { transcriptId }, -}: TranscriptCorrect) { - const api = useApi(); - const transcript = useTranscript(transcriptId); +export default function TranscriptCorrect(props: TranscriptCorrect) { + const params = use(props.params); + + const { transcriptId } = params; + + const updateTranscriptMutation = useTranscriptUpdate(); + const transcript = useTranscriptGet(transcriptId); const stateCurrentTopic = useState(); const [currentTopic, _sct] = stateCurrentTopic; const stateSelectedText = useState(); @@ -34,16 +39,21 @@ export default function TranscriptCorrect({ const { setError } = useError(); const router = useRouter(); - const markAsDone = () => { - if (transcript.response && !transcript.response.reviewed) { - api - ?.v1TranscriptUpdate({ transcriptId, requestBody: { reviewed: true } }) - .then(() => { - router.push(`/transcripts/${transcriptId}`); - }) - .catch((e) => { - setError(e, "Error marking as done"); + const markAsDone = async () => { + if (transcript.data && !transcript.data.reviewed) { + try { + await updateTranscriptMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: { reviewed: true }, }); + router.push(`/transcripts/${transcriptId}`); + } catch (e) { + setError(e as Error, "Error marking as done"); + } } }; @@ -108,7 +118,7 @@ export default function TranscriptCorrect({ }} /> - {transcript.response && !transcript.response?.reviewed && ( + {transcript.data && !transcript.data?.reviewed && (
- + )} {!isEditMode && ( @@ -128,11 +129,6 @@ export default function FinalSummary(props: FinalSummaryProps) { > - )} @@ -148,7 +144,7 @@ export default function FinalSummary(props: FinalSummaryProps) { mt={2} /> ) : ( -
+
{editedSummary}
)} diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx index 0a2dba47..1e020f1c 100644 --- a/www/app/(app)/transcripts/[transcriptId]/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx @@ -1,32 +1,47 @@ "use client"; import Modal from "../modal"; -import useTranscript from "../useTranscript"; import useTopics from "../useTopics"; import useWaveform from "../useWaveform"; import useMp3 from "../useMp3"; import { TopicList } from "./_components/TopicList"; import { Topic } from "../webSocketTypes"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, use } from "react"; import FinalSummary from "./finalSummary"; import TranscriptTitle from "../transcriptTitle"; import Player from "../player"; import { useRouter } from "next/navigation"; -import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react"; +import { + Box, + Flex, + Grid, + GridItem, + Skeleton, + Text, + Spinner, +} from "@chakra-ui/react"; +import { useTranscriptGet } from "../../../lib/apiHooks"; +import { TranscriptStatus } from "../../../lib/transcript"; type TranscriptDetails = { - params: { + params: Promise<{ transcriptId: string; - }; + }>; }; export default function TranscriptDetails(details: TranscriptDetails) { - const transcriptId = details.params.transcriptId; + const params = use(details.params); + const transcriptId = params.transcriptId; const router = useRouter(); - const statusToRedirect = ["idle", "recording", "processing"]; + const statusToRedirect = [ + "idle", + "recording", + "processing", + "uploaded", + ] satisfies TranscriptStatus[] as TranscriptStatus[]; - const transcript = useTranscript(transcriptId); - const transcriptStatus = transcript.response?.status; - const waiting = statusToRedirect.includes(transcriptStatus || ""); + const transcript = useTranscriptGet(transcriptId); + const waiting = + transcript.data && statusToRedirect.includes(transcript.data.status); const mp3 = useMp3(transcriptId, waiting); const topics = useTopics(transcriptId); @@ -35,17 +50,59 @@ export default function TranscriptDetails(details: TranscriptDetails) { waiting || mp3.audioDeleted === true, ); const useActiveTopic = useState(null); + const [finalSummaryElement, setFinalSummaryElement] = + useState(null); useEffect(() => { - if (waiting) { - const newUrl = "/transcripts/" + details.params.transcriptId + "/record"; + if (!waiting || !transcript.data) return; + + const status = transcript.data.status; + let newUrl: string | null = null; + + if (status === "processing" || status === "uploaded") { + newUrl = `/transcripts/${params.transcriptId}/processing`; + } else if (status === "recording") { + newUrl = `/transcripts/${params.transcriptId}/record`; + } else if (status === "idle") { + newUrl = + transcript.data.source_kind === "file" + ? `/transcripts/${params.transcriptId}/upload` + : `/transcripts/${params.transcriptId}/record`; + } + + if (newUrl) { // Shallow redirection does not work on NextJS 13 // https://github.com/vercel/next.js/discussions/48110 // https://github.com/vercel/next.js/discussions/49540 router.replace(newUrl); - // history.replaceState({}, "", newUrl); } - }, [waiting]); + }, [waiting, transcript.data?.status, transcript.data?.source_kind]); + + if (waiting) { + return ( + + + + + + Loading transcript... + + + + + ); + } if (transcript.error || topics?.error) { return ( @@ -56,7 +113,7 @@ export default function TranscriptDetails(details: TranscriptDetails) { ); } - if (transcript?.loading || topics?.loading) { + if (transcript?.isLoading || topics?.loading) { return ; } @@ -86,7 +143,7 @@ export default function TranscriptDetails(details: TranscriptDetails) { useActiveTopic={useActiveTopic} waveform={waveform.waveform} media={mp3.media} - mediaDuration={transcript.response.duration} + mediaDuration={transcript.data?.duration || null} /> ) : !mp3.loading && (waveform.error || mp3.error) ? ( @@ -116,11 +173,14 @@ export default function TranscriptDetails(details: TranscriptDetails) { { - transcript.reload(); + onUpdate={() => { + transcript.refetch().then(() => {}); }} + transcript={transcript.data || null} + topics={topics.topics} + finalSummaryElement={finalSummaryElement} /> {mp3.audioDeleted && ( @@ -136,23 +196,24 @@ export default function TranscriptDetails(details: TranscriptDetails) { useActiveTopic={useActiveTopic} autoscroll={false} transcriptId={transcriptId} - status={transcript.response?.status} + status={transcript.data?.status || null} currentTranscriptText="" /> - {transcript.response && topics.topics ? ( + {transcript.data && topics.topics ? ( <> { - transcript.reload(); + transcript={transcript.data} + topics={topics.topics} + onUpdate={() => { + transcript.refetch().then(() => {}); }} + finalSummaryRef={setFinalSummaryElement} /> ) : (
- {transcript.response.status == "processing" ? ( + {transcript?.data?.status == "processing" ? ( Loading Transcript ) : ( diff --git a/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx b/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx new file mode 100644 index 00000000..4422e077 --- /dev/null +++ b/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx @@ -0,0 +1,97 @@ +"use client"; +import { useEffect, use } from "react"; +import { + Heading, + Text, + VStack, + Spinner, + Button, + Center, +} from "@chakra-ui/react"; +import { useRouter } from "next/navigation"; +import { useTranscriptGet } from "../../../../lib/apiHooks"; + +type TranscriptProcessing = { + params: Promise<{ + transcriptId: string; + }>; +}; + +export default function TranscriptProcessing(details: TranscriptProcessing) { + const params = use(details.params); + const transcriptId = params.transcriptId; + const router = useRouter(); + + const transcript = useTranscriptGet(transcriptId); + + useEffect(() => { + const status = transcript.data?.status; + if (!status) return; + + if (status === "ended" || status === "error") { + router.replace(`/transcripts/${transcriptId}`); + } else if (status === "recording") { + router.replace(`/transcripts/${transcriptId}/record`); + } else if (status === "idle") { + const dest = + transcript.data?.source_kind === "file" + ? `/transcripts/${transcriptId}/upload` + : `/transcripts/${transcriptId}/record`; + router.replace(dest); + } + }, [ + transcript.data?.status, + transcript.data?.source_kind, + router, + transcriptId, + ]); + + if (transcript.isLoading) { + return ( + + Loading transcript... + + ); + } + + if (transcript.error) { + return ( + + Transcript not found + We couldn't load this transcript. + + ); + } + + return ( + <> + +
+ + + + Processing recording + + + You can safely return to the library while your recording is being + processed. + + + +
+
+ + ); +} diff --git a/www/app/(app)/transcripts/[transcriptId]/record/page.tsx b/www/app/(app)/transcripts/[transcriptId]/record/page.tsx index 8f6634b0..d93b34b6 100644 --- a/www/app/(app)/transcripts/[transcriptId]/record/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/record/page.tsx @@ -1,8 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, use } from "react"; import Recorder from "../../recorder"; import { TopicList } from "../_components/TopicList"; -import useTranscript from "../../useTranscript"; import { useWebSockets } from "../../useWebSockets"; import { Topic } from "../../webSocketTypes"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; @@ -11,26 +10,29 @@ import useMp3 from "../../useMp3"; import WaveformLoading from "../../waveformLoading"; import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react"; import LiveTrancription from "../../liveTranscription"; +import { useTranscriptGet } from "../../../../lib/apiHooks"; +import { TranscriptStatus } from "../../../../lib/transcript"; type TranscriptDetails = { - params: { + params: Promise<{ transcriptId: string; - }; + }>; }; const TranscriptRecord = (details: TranscriptDetails) => { - const transcript = useTranscript(details.params.transcriptId); + const params = use(details.params); + const transcript = useTranscriptGet(params.transcriptId); const [transcriptStarted, setTranscriptStarted] = useState(false); const useActiveTopic = useState(null); - const webSockets = useWebSockets(details.params.transcriptId); + const webSockets = useWebSockets(params.transcriptId); - const mp3 = useMp3(details.params.transcriptId, true); + const mp3 = useMp3(params.transcriptId, true); const router = useRouter(); - const [status, setStatus] = useState( - webSockets.status.value || transcript.response?.status || "idle", + const [status, setStatus] = useState( + webSockets.status?.value || transcript.data?.status || "idle", ); useEffect(() => { @@ -41,15 +43,15 @@ const TranscriptRecord = (details: TranscriptDetails) => { useEffect(() => { //TODO HANDLE ERROR STATUS BETTER const newStatus = - webSockets.status.value || transcript.response?.status || "idle"; + webSockets.status?.value || transcript.data?.status || "idle"; setStatus(newStatus); if (newStatus && (newStatus == "ended" || newStatus == "error")) { console.log(newStatus, "redirecting"); - const newUrl = "/transcripts/" + details.params.transcriptId; + const newUrl = "/transcripts/" + params.transcriptId; router.replace(newUrl); } - }, [webSockets.status.value, transcript.response?.status]); + }, [webSockets.status?.value, transcript.data?.status]); useEffect(() => { if (webSockets.waveform && webSockets.waveform) mp3.getNow(); @@ -74,7 +76,7 @@ const TranscriptRecord = (details: TranscriptDetails) => { ) : ( // todo: only start recording animation when you get "recorded" status - + )} { topics={webSockets.topics} useActiveTopic={useActiveTopic} autoscroll={true} - transcriptId={details.params.transcriptId} + transcriptId={params.transcriptId} status={status} currentTranscriptText={webSockets.accumulatedText} /> diff --git a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx index 3a13052e..9fc6a687 100644 --- a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx @@ -1,33 +1,40 @@ "use client"; -import { useEffect, useState } from "react"; -import useTranscript from "../../useTranscript"; +import { useEffect, useState, use } from "react"; import { useWebSockets } from "../../useWebSockets"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { useRouter } from "next/navigation"; import useMp3 from "../../useMp3"; -import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react"; +import { Center, VStack, Text, Heading } from "@chakra-ui/react"; import FileUploadButton from "../../fileUploadButton"; +import { useTranscriptGet } from "../../../../lib/apiHooks"; type TranscriptUpload = { - params: { + params: Promise<{ transcriptId: string; - }; + }>; }; const TranscriptUpload = (details: TranscriptUpload) => { - const transcript = useTranscript(details.params.transcriptId); + const params = use(details.params); + const transcript = useTranscriptGet(params.transcriptId); const [transcriptStarted, setTranscriptStarted] = useState(false); - const webSockets = useWebSockets(details.params.transcriptId); + const webSockets = useWebSockets(params.transcriptId); - const mp3 = useMp3(details.params.transcriptId, true); + const mp3 = useMp3(params.transcriptId, true); const router = useRouter(); - const [status, setStatus] = useState( - webSockets.status.value || transcript.response?.status || "idle", + const [status_, setStatus] = useState( + webSockets.status?.value || transcript.data?.status || "idle", ); + // status is obviously done if we have transcript + const status = + !transcript.isLoading && transcript.data?.status === "ended" + ? transcript.data?.status + : status_; + useEffect(() => { if (!transcriptStarted && webSockets.transcriptTextLive.length !== 0) setTranscriptStarted(true); @@ -35,16 +42,25 @@ const TranscriptUpload = (details: TranscriptUpload) => { useEffect(() => { //TODO HANDLE ERROR STATUS BETTER + // TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib const newStatus = - webSockets.status.value || transcript.response?.status || "idle"; + transcript.data?.status === "ended" + ? "ended" + : webSockets.status?.value || transcript.data?.status || "idle"; setStatus(newStatus); if (newStatus && (newStatus == "ended" || newStatus == "error")) { console.log(newStatus, "redirecting"); - const newUrl = "/transcripts/" + details.params.transcriptId; + const newUrl = "/transcripts/" + params.transcriptId; router.replace(newUrl); + } else if ( + newStatus && + (newStatus == "uploaded" || newStatus == "processing") + ) { + // After upload finishes (or if already processing), redirect to the unified processing page + router.replace(`/transcripts/${params.transcriptId}/processing`); } - }, [webSockets.status.value, transcript.response?.status]); + }, [webSockets.status?.value, transcript.data?.status]); useEffect(() => { if (webSockets.waveform && webSockets.waveform) mp3.getNow(); @@ -61,7 +77,7 @@ const TranscriptUpload = (details: TranscriptUpload) => { <> { Upload meeting
- {status && status == "idle" && ( - <> - - Please select the file, supported formats: .mp3, m4a, .wav, - .mp4, .mov or .webm - - - - )} - {status && status == "uploaded" && ( - File is uploaded, processing... - )} - {(status == "recording" || status == "processing") && ( - <> - Processing your recording... - - You can safely return to the library while your file is being - processed. - - - - )} + + Please select the file, supported formats: .mp3, m4a, .wav, .mp4, + .mov or .webm + + + router.replace(`/transcripts/${params.transcriptId}/processing`) + } + />
diff --git a/www/app/(app)/transcripts/buildTranscriptWithTopics.ts b/www/app/(app)/transcripts/buildTranscriptWithTopics.ts new file mode 100644 index 00000000..71553d31 --- /dev/null +++ b/www/app/(app)/transcripts/buildTranscriptWithTopics.ts @@ -0,0 +1,60 @@ +import type { components } from "../../reflector-api"; +import { formatTime } from "../../lib/time"; + +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; +type Participant = components["schemas"]["Participant"]; + +function getSpeakerName( + speakerNumber: number, + participants?: Participant[] | null, +): string { + const name = participants?.find((p) => p.speaker === speakerNumber)?.name; + return name && name.trim().length > 0 ? name : `Speaker ${speakerNumber}`; +} + +export function buildTranscriptWithTopics( + topics: GetTranscriptTopic[], + participants?: Participant[] | null, + transcriptTitle?: string | null, +): string { + const blocks: string[] = []; + + if (transcriptTitle && transcriptTitle.trim()) { + blocks.push(`# ${transcriptTitle.trim()}`); + blocks.push(""); + } + + for (const topic of topics) { + // Topic header + const topicTime = formatTime(Math.floor(topic.timestamp || 0)); + const title = topic.title?.trim() || "Untitled Topic"; + blocks.push(`## ${title} [${topicTime}]`); + + if (topic.segments && topic.segments.length > 0) { + for (const seg of topic.segments) { + const ts = formatTime(Math.floor(seg.start || 0)); + const speaker = getSpeakerName(seg.speaker as number, participants); + const text = (seg.text || "").replace(/\s+/g, " ").trim(); + if (text) { + blocks.push(`[${ts}] ${speaker}: ${text}`); + } + } + } else if (topic.transcript) { + // Fallback: plain transcript when segments are not present + const text = topic.transcript.replace(/\s+/g, " ").trim(); + if (text) { + blocks.push(text); + } + } + + // Blank line between topics + blocks.push(""); + } + + // Trim trailing blank line + while (blocks.length > 0 && blocks[blocks.length - 1] === "") { + blocks.pop(); + } + + return blocks.join("\n"); +} diff --git a/www/app/(app)/transcripts/createTranscript.ts b/www/app/(app)/transcripts/createTranscript.ts index 015c82de..8a235161 100644 --- a/www/app/(app)/transcripts/createTranscript.ts +++ b/www/app/(app)/transcripts/createTranscript.ts @@ -1,45 +1,33 @@ -import { useEffect, useState } from "react"; +import type { components } from "../../reflector-api"; +import { useTranscriptCreate } from "../../lib/apiHooks"; -import { useError } from "../../(errors)/errorContext"; -import { CreateTranscript, GetTranscript } from "../../api"; -import useApi from "../../lib/useApi"; +type CreateTranscript = components["schemas"]["CreateTranscript"]; +type GetTranscript = components["schemas"]["GetTranscript"]; type UseCreateTranscript = { transcript: GetTranscript | null; loading: boolean; error: Error | null; - create: (transcriptCreationDetails: CreateTranscript) => void; + create: (transcriptCreationDetails: CreateTranscript) => Promise; }; const useCreateTranscript = (): UseCreateTranscript => { - const [transcript, setTranscript] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); + const createMutation = useTranscriptCreate(); - const create = (transcriptCreationDetails: CreateTranscript) => { - if (loading || !api) return; + const create = async (transcriptCreationDetails: CreateTranscript) => { + if (createMutation.isPending) return; - setLoading(true); - - api - .v1TranscriptsCreate({ requestBody: transcriptCreationDetails }) - .then((transcript) => { - setTranscript(transcript); - setLoading(false); - }) - .catch((err) => { - setError( - err, - "There was an issue creating a transcript, please try again.", - ); - setErrorState(err); - setLoading(false); - }); + await createMutation.mutateAsync({ + body: transcriptCreationDetails, + }); }; - return { transcript, loading, error, create }; + return { + transcript: createMutation.data || null, + loading: createMutation.isPending, + error: createMutation.error as Error | null, + create, + }; }; export default useCreateTranscript; diff --git a/www/app/(app)/transcripts/fileUploadButton.tsx b/www/app/(app)/transcripts/fileUploadButton.tsx index 1b4101e8..b5fda7b6 100644 --- a/www/app/(app)/transcripts/fileUploadButton.tsx +++ b/www/app/(app)/transcripts/fileUploadButton.tsx @@ -1,20 +1,25 @@ import React, { useState } from "react"; -import useApi from "../../lib/useApi"; +import { useTranscriptUploadAudio } from "../../lib/apiHooks"; import { Button, Spinner } from "@chakra-ui/react"; +import { useError } from "../../(errors)/errorContext"; type FileUploadButton = { transcriptId: string; + onUploadComplete?: () => void; }; export default function FileUploadButton(props: FileUploadButton) { const fileInputRef = React.useRef(null); - const api = useApi(); + const uploadMutation = useTranscriptUploadAudio(); + const { setError } = useError(); const [progress, setProgress] = useState(0); const triggerFileUpload = () => { fileInputRef.current?.click(); }; - const handleFileUpload = (event: React.ChangeEvent) => { + const handleFileUpload = async ( + event: React.ChangeEvent, + ) => { const file = event.target.files?.[0]; if (file) { @@ -24,37 +29,46 @@ export default function FileUploadButton(props: FileUploadButton) { let start = 0; let uploadedSize = 0; - api?.httpRequest.config.interceptors.request.use((request) => { - request.onUploadProgress = (progressEvent) => { - const currentProgress = Math.floor( - ((uploadedSize + progressEvent.loaded) / file.size) * 100, - ); - setProgress(currentProgress); - }; - return request; - }); - const uploadNextChunk = async () => { - if (chunkNumber == totalChunks) return; + if (chunkNumber == totalChunks) { + setProgress(0); + props.onUploadComplete?.(); + return; + } const chunkSize = Math.min(maxChunkSize, file.size - start); const end = start + chunkSize; const chunk = file.slice(start, end); - await api?.v1TranscriptRecordUpload({ - transcriptId: props.transcriptId, - formData: { - chunk, - }, - chunkNumber, - totalChunks, - }); + try { + const formData = new FormData(); + formData.append("chunk", chunk); - uploadedSize += chunkSize; - chunkNumber++; - start = end; + await uploadMutation.mutateAsync({ + params: { + path: { + transcript_id: props.transcriptId, + }, + query: { + chunk_number: chunkNumber, + total_chunks: totalChunks, + }, + }, + body: formData as any, + }); - uploadNextChunk(); + uploadedSize += chunkSize; + const currentProgress = Math.floor((uploadedSize / file.size) * 100); + setProgress(currentProgress); + + chunkNumber++; + start = end; + + await uploadNextChunk(); + } catch (error) { + setError(error as Error, "Failed to upload file"); + setProgress(0); + } }; uploadNextChunk(); diff --git a/www/app/(app)/transcripts/new/page.tsx b/www/app/(app)/transcripts/new/page.tsx index 698ac47b..8953e994 100644 --- a/www/app/(app)/transcripts/new/page.tsx +++ b/www/app/(app)/transcripts/new/page.tsx @@ -9,33 +9,25 @@ import { useRouter } from "next/navigation"; import useCreateTranscript from "../createTranscript"; import SelectSearch from "react-select-search"; import { supportedLanguages } from "../../../supportedLanguages"; -import useSessionStatus from "../../../lib/useSessionStatus"; -import { featureEnabled } from "../../../domainContext"; -import { signIn } from "next-auth/react"; import { Flex, Box, Spinner, Heading, Button, - Card, Center, - Link, - CardBody, - Stack, Text, - Icon, - Grid, - IconButton, Spacer, - Menu, - Tooltip, - Input, } from "@chakra-ui/react"; +import { useAuth } from "../../../lib/AuthProvider"; +import { featureEnabled } from "../../../lib/features"; + const TranscriptCreate = () => { - const isClient = typeof window !== "undefined"; const router = useRouter(); - const { isLoading, isAuthenticated } = useSessionStatus(); + const auth = useAuth(); + const isAuthenticated = auth.status === "authenticated"; + const isAuthRefreshing = auth.status === "refreshing"; + const isLoading = auth.status === "loading"; const requireLogin = featureEnabled("requireLogin"); const [name, setName] = useState(""); @@ -54,20 +46,32 @@ const TranscriptCreate = () => { const [loadingUpload, setLoadingUpload] = useState(false); const getTargetLanguage = () => { - if (targetLanguage === "NOTRANSLATION") return; + if (targetLanguage === "NOTRANSLATION") return undefined; return targetLanguage; }; const send = () => { if (loadingRecord || createTranscript.loading || permissionDenied) return; setLoadingRecord(true); - createTranscript.create({ name, target_language: getTargetLanguage() }); + const targetLang = getTargetLanguage(); + createTranscript.create({ + name, + source_language: "en", + target_language: targetLang || "en", + source_kind: "live", + }); }; const uploadFile = () => { if (loadingUpload || createTranscript.loading || permissionDenied) return; setLoadingUpload(true); - createTranscript.create({ name, target_language: getTargetLanguage() }); + const targetLang = getTargetLanguage(); + createTranscript.create({ + name, + source_language: "en", + target_language: targetLang || "en", + source_kind: "file", + }); }; useEffect(() => { @@ -132,8 +136,8 @@ const TranscriptCreate = () => {
{isLoading ? ( - ) : requireLogin && !isAuthenticated ? ( - + ) : requireLogin && !isAuthenticated && !isAuthRefreshing ? ( + ) : ( { placeholder="Choose your language" /> - {isClient && !loading ? ( + {!loading ? ( permissionOk ? ( ) : permissionDenied ? ( diff --git a/www/app/(app)/transcripts/player.tsx b/www/app/(app)/transcripts/player.tsx index c8163ecb..2cefe8c1 100644 --- a/www/app/(app)/transcripts/player.tsx +++ b/www/app/(app)/transcripts/player.tsx @@ -5,7 +5,9 @@ import RegionsPlugin from "wavesurfer.js/dist/plugins/regions.esm.js"; import { formatTime, formatTimeMs } from "../../lib/time"; import { Topic } from "./webSocketTypes"; -import { AudioWaveform } from "../../api"; +import type { components } from "../../reflector-api"; + +type AudioWaveform = components["schemas"]["AudioWaveform"]; import { waveSurferStyles } from "../../styles/recorder"; import { Box, Flex, IconButton } from "@chakra-ui/react"; import { LuPause, LuPlay } from "react-icons/lu"; @@ -18,7 +20,7 @@ type PlayerProps = { ]; waveform: AudioWaveform; media: HTMLMediaElement; - mediaDuration: number; + mediaDuration: number | null; }; export default function Player(props: PlayerProps) { @@ -31,17 +33,27 @@ export default function Player(props: PlayerProps) { const topicsRef = useRef(props.topics); const [firstRender, setFirstRender] = useState(true); - const keyHandler = (e) => { - if (e.key == " ") { + const shouldIgnoreHotkeys = (target: EventTarget | null) => { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement + ); + }; + + const keyHandler = (e: KeyboardEvent) => { + if (e.key === " ") { + if (e.repeat) return; + if (shouldIgnoreHotkeys(e.target)) return; + e.preventDefault(); wavesurfer?.playPause(); } }; useEffect(() => { - document.addEventListener("keyup", keyHandler); + document.addEventListener("keydown", keyHandler); return () => { - document.removeEventListener("keyup", keyHandler); + document.removeEventListener("keydown", keyHandler); }; - }); + }, [wavesurfer]); // Waveform setup useEffect(() => { @@ -50,7 +62,9 @@ export default function Player(props: PlayerProps) { container: waveformRef.current, peaks: [props.waveform.data], height: "auto", - duration: Math.floor(props.mediaDuration / 1000), + duration: props.mediaDuration + ? Math.floor(props.mediaDuration / 1000) + : undefined, media: props.media, ...waveSurferStyles.playerSettings, diff --git a/www/app/(app)/transcripts/recorder.tsx b/www/app/(app)/transcripts/recorder.tsx index f57540d4..1cf68c39 100644 --- a/www/app/(app)/transcripts/recorder.tsx +++ b/www/app/(app)/transcripts/recorder.tsx @@ -6,16 +6,16 @@ import RecordPlugin from "../../lib/custom-plugins/record"; import { formatTime, formatTimeMs } from "../../lib/time"; import { waveSurferStyles } from "../../styles/recorder"; import { useError } from "../../(errors)/errorContext"; -import FileUploadButton from "./fileUploadButton"; import useWebRTC from "./useWebRTC"; import useAudioDevice from "./useAudioDevice"; import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react"; import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu"; import { RECORD_A_MEETING_URL } from "../../api/urls"; +import { TranscriptStatus } from "../../lib/transcript"; type RecorderProps = { transcriptId: string; - status: string; + status: TranscriptStatus; }; export default function Recorder(props: RecorderProps) { diff --git a/www/app/(app)/transcripts/shareAndPrivacy.tsx b/www/app/(app)/transcripts/shareAndPrivacy.tsx index 609793d3..04cda920 100644 --- a/www/app/(app)/transcripts/shareAndPrivacy.tsx +++ b/www/app/(app)/transcripts/shareAndPrivacy.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; -import { featureEnabled } from "../../domainContext"; import { ShareMode, toShareMode } from "../../lib/shareMode"; -import { GetTranscript, GetTranscriptTopic, UpdateTranscript } from "../../api"; +import type { components } from "../../reflector-api"; +type GetTranscript = components["schemas"]["GetTranscript"]; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; +type UpdateTranscript = components["schemas"]["UpdateTranscript"]; import { Box, Flex, @@ -15,17 +17,18 @@ import { createListCollection, } from "@chakra-ui/react"; import { LuShare2 } from "react-icons/lu"; -import useApi from "../../lib/useApi"; -import useSessionUser from "../../lib/useSessionUser"; -import { CustomSession } from "../../lib/types"; +import { useTranscriptUpdate } from "../../lib/apiHooks"; import ShareLink from "./shareLink"; import ShareCopy from "./shareCopy"; import ShareZulip from "./shareZulip"; +import { useAuth } from "../../lib/AuthProvider"; + +import { featureEnabled } from "../../lib/features"; type ShareAndPrivacyProps = { - finalSummaryRef: any; - transcriptResponse: GetTranscript; - topicsResponse: GetTranscriptTopic[]; + finalSummaryElement: HTMLDivElement | null; + transcript: GetTranscript; + topics: GetTranscriptTopic[]; }; type ShareOption = { value: ShareMode; label: string }; @@ -45,17 +48,14 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { const [isOwner, setIsOwner] = useState(false); const [shareMode, setShareMode] = useState( shareOptionsData.find( - (option) => option.value === props.transcriptResponse.share_mode, + (option) => option.value === props.transcript.share_mode, ) || shareOptionsData[0], ); const [shareLoading, setShareLoading] = useState(false); const requireLogin = featureEnabled("requireLogin"); - const api = useApi(); + const updateTranscriptMutation = useTranscriptUpdate(); const updateShareMode = async (selectedValue: string) => { - if (!api) - throw new Error("ShareLink's API should always be ready at this point"); - const selectedOption = shareOptionsData.find( (option) => option.value === selectedValue, ); @@ -67,23 +67,31 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { share_mode: selectedValue as "public" | "semi-private" | "private", }; - const updatedTranscript = await api.v1TranscriptUpdate({ - transcriptId: props.transcriptResponse.id, - requestBody, - }); - setShareMode( - shareOptionsData.find( - (option) => option.value === updatedTranscript.share_mode, - ) || shareOptionsData[0], - ); - setShareLoading(false); + try { + const updatedTranscript = await updateTranscriptMutation.mutateAsync({ + params: { + path: { transcript_id: props.transcript.id }, + }, + body: requestBody, + }); + setShareMode( + shareOptionsData.find( + (option) => option.value === updatedTranscript.share_mode, + ) || shareOptionsData[0], + ); + } catch (err) { + console.error("Failed to update share mode:", err); + } finally { + setShareLoading(false); + } }; - const userId = useSessionUser().id; + const auth = useAuth(); + const userId = auth.status === "authenticated" ? auth.user?.id : null; useEffect(() => { - setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id)); - }, [userId, props.transcriptResponse.user_id]); + setIsOwner(!!(requireLogin && userId === props.transcript.user_id)); + }, [userId, props.transcript.user_id]); return ( <> @@ -124,7 +132,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { "This transcript is public. Everyone can access it."} - {isOwner && api && ( + {isOwner && ( {requireLogin && ( )} - + diff --git a/www/app/(app)/transcripts/shareCopy.tsx b/www/app/(app)/transcripts/shareCopy.tsx index 90e1534f..bdbff5f4 100644 --- a/www/app/(app)/transcripts/shareCopy.tsx +++ b/www/app/(app)/transcripts/shareCopy.tsx @@ -1,40 +1,46 @@ import { useState } from "react"; -import { GetTranscript, GetTranscriptTopic } from "../../api"; +import type { components } from "../../reflector-api"; +type GetTranscript = components["schemas"]["GetTranscript"]; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; import { Button, BoxProps, Box } from "@chakra-ui/react"; +import { buildTranscriptWithTopics } from "./buildTranscriptWithTopics"; +import { useTranscriptParticipants } from "../../lib/apiHooks"; type ShareCopyProps = { - finalSummaryRef: any; - transcriptResponse: GetTranscript; - topicsResponse: GetTranscriptTopic[]; + finalSummaryElement: HTMLDivElement | null; + transcript: GetTranscript; + topics: GetTranscriptTopic[]; }; export default function ShareCopy({ - finalSummaryRef, - transcriptResponse, - topicsResponse, + finalSummaryElement, + transcript, + topics, ...boxProps }: ShareCopyProps & BoxProps) { const [isCopiedSummary, setIsCopiedSummary] = useState(false); const [isCopiedTranscript, setIsCopiedTranscript] = useState(false); + const participantsQuery = useTranscriptParticipants(transcript?.id || null); const onCopySummaryClick = () => { - let text_to_copy = finalSummaryRef.current?.innerText; + const text_to_copy = finalSummaryElement?.innerText; - text_to_copy && + if (text_to_copy) { navigator.clipboard.writeText(text_to_copy).then(() => { setIsCopiedSummary(true); // Reset the copied state after 2 seconds setTimeout(() => setIsCopiedSummary(false), 2000); }); + } }; const onCopyTranscriptClick = () => { - let text_to_copy = - topicsResponse - ?.map((topic) => topic.transcript) - .join("\n\n") - .replace(/ +/g, " ") - .trim() || ""; + const text_to_copy = + buildTranscriptWithTopics( + topics || [], + participantsQuery?.data || null, + transcript?.title || null, + ) || ""; text_to_copy && navigator.clipboard.writeText(text_to_copy).then(() => { diff --git a/www/app/(app)/transcripts/shareLink.tsx b/www/app/(app)/transcripts/shareLink.tsx index 7ea55f5e..ee7a01bf 100644 --- a/www/app/(app)/transcripts/shareLink.tsx +++ b/www/app/(app)/transcripts/shareLink.tsx @@ -1,8 +1,9 @@ import React, { useState, useRef, useEffect, use } from "react"; -import { featureEnabled } from "../../domainContext"; import { Button, Flex, Input, Text } from "@chakra-ui/react"; import QRCode from "react-qr-code"; +import { featureEnabled } from "../../lib/features"; + type ShareLinkProps = { transcriptId: string; }; diff --git a/www/app/(app)/transcripts/shareZulip.tsx b/www/app/(app)/transcripts/shareZulip.tsx index b310c431..c3efe3ab 100644 --- a/www/app/(app)/transcripts/shareZulip.tsx +++ b/www/app/(app)/transcripts/shareZulip.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useMemo } from "react"; -import { featureEnabled } from "../../domainContext"; -import { GetTranscript, GetTranscriptTopic } from "../../api"; +import type { components } from "../../reflector-api"; + +type GetTranscript = components["schemas"]["GetTranscript"]; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; import { BoxProps, Button, @@ -12,16 +14,20 @@ import { Checkbox, Combobox, Spinner, - Portal, - useFilter, - useListCollection, + createListCollection, } from "@chakra-ui/react"; import { TbBrandZulip } from "react-icons/tb"; -import useApi from "../../lib/useApi"; +import { + useZulipStreams, + useZulipTopics, + useTranscriptPostToZulip, +} from "../../lib/apiHooks"; + +import { featureEnabled } from "../../lib/features"; type ShareZulipProps = { - transcriptResponse: GetTranscript; - topicsResponse: GetTranscriptTopic[]; + transcript: GetTranscript; + topics: GetTranscriptTopic[]; disabled: boolean; }; @@ -30,104 +36,77 @@ interface Stream { name: string; } -interface Topic { - name: string; -} - export default function ShareZulip(props: ShareZulipProps & BoxProps) { const [showModal, setShowModal] = useState(false); const [stream, setStream] = useState(undefined); + const [selectedStreamId, setSelectedStreamId] = useState(null); const [topic, setTopic] = useState(undefined); const [includeTopics, setIncludeTopics] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [streams, setStreams] = useState([]); - const [topics, setTopics] = useState([]); - const api = useApi(); - const { contains } = useFilter({ sensitivity: "base" }); - const { - collection: streamItemsCollection, - filter: streamItemsFilter, - set: streamItemsSet, - } = useListCollection({ - initialItems: [] as { label: string; value: string }[], - filter: contains, - }); + const { data: streams = [], isLoading: isLoadingStreams } = useZulipStreams(); + const { data: topics = [] } = useZulipTopics(selectedStreamId); + const postToZulipMutation = useTranscriptPostToZulip(); - const { - collection: topicItemsCollection, - filter: topicItemsFilter, - set: topicItemsSet, - } = useListCollection({ - initialItems: [] as { label: string; value: string }[], - filter: contains, - }); + const streamItems = useMemo(() => { + return streams.map((stream: Stream) => ({ + label: stream.name, + value: stream.name, + })); + }, [streams]); + const topicItems = useMemo(() => { + return topics.map(({ name }) => ({ + label: name, + value: name, + })); + }, [topics]); + + const streamCollection = useMemo( + () => + createListCollection({ + items: streamItems, + }), + [streamItems], + ); + + const topicCollection = useMemo( + () => + createListCollection({ + items: topicItems, + }), + [topicItems], + ); + + // Update selected stream ID when stream changes useEffect(() => { - const fetchZulipStreams = async () => { - if (!api) return; - - try { - const response = await api.v1ZulipGetStreams(); - setStreams(response); - - streamItemsSet( - response.map((stream) => ({ - label: stream.name, - value: stream.name, - })), - ); - - setIsLoading(false); - } catch (error) { - console.error("Error fetching Zulip streams:", error); - } - }; - - fetchZulipStreams(); - }, [!api]); - - useEffect(() => { - const fetchZulipTopics = async () => { - if (!api || !stream) return; - try { - const selectedStream = streams.find((s) => s.name === stream); - if (selectedStream) { - const response = await api.v1ZulipGetTopics({ - streamId: selectedStream.stream_id, - }); - setTopics(response); - topicItemsSet( - response.map((topic) => ({ - label: topic.name, - value: topic.name, - })), - ); - } else { - topicItemsSet([]); - } - } catch (error) { - console.error("Error fetching Zulip topics:", error); - } - }; - - fetchZulipTopics(); - }, [stream, streams, api]); + if (stream && streams) { + const selectedStream = streams.find((s: Stream) => s.name === stream); + setSelectedStreamId(selectedStream ? selectedStream.stream_id : null); + } else { + setSelectedStreamId(null); + } + }, [stream, streams]); const handleSendToZulip = async () => { - if (!api || !props.transcriptResponse) return; + if (!props.transcript) return; if (stream && topic) { try { - await api.v1TranscriptPostToZulip({ - transcriptId: props.transcriptResponse.id, - stream, - topic, - includeTopics, + await postToZulipMutation.mutateAsync({ + params: { + path: { + transcript_id: props.transcript.id, + }, + query: { + stream, + topic, + include_topics: includeTopics, + }, + }, }); setShowModal(false); } catch (error) { - console.log(error); + console.error("Error posting to Zulip:", error); } } }; @@ -155,7 +134,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) { - {isLoading ? ( + {isLoadingStreams ? ( @@ -178,15 +157,12 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) { # { setTopic(undefined); setStream(e.value[0]); }} - onInputValueChange={(e) => - streamItemsFilter(e.inputValue) - } openOnClick={true} positioning={{ strategy: "fixed", @@ -203,7 +179,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) { No streams found - {streamItemsCollection.items.map((item) => ( + {streamItems.map((item) => ( {item.label} @@ -219,12 +195,9 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) { # setTopic(e.value[0])} - onInputValueChange={(e) => - topicItemsFilter(e.inputValue) - } openOnClick selectionBehavior="replace" skipAnimationOnMount={true} @@ -244,7 +217,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) { No topics found - {topicItemsCollection.items.map((item) => ( + {topicItems.map((item) => ( {item.label} diff --git a/www/app/(app)/transcripts/transcriptTitle.tsx b/www/app/(app)/transcripts/transcriptTitle.tsx index 4678818f..49a22c71 100644 --- a/www/app/(app)/transcripts/transcriptTitle.tsx +++ b/www/app/(app)/transcripts/transcriptTitle.tsx @@ -1,37 +1,56 @@ import { useState } from "react"; -import { UpdateTranscript } from "../../api"; -import useApi from "../../lib/useApi"; +import type { components } from "../../reflector-api"; + +type UpdateTranscript = components["schemas"]["UpdateTranscript"]; +type GetTranscript = components["schemas"]["GetTranscript"]; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; +import { + useTranscriptUpdate, + useTranscriptParticipants, +} from "../../lib/apiHooks"; import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react"; -import { LuPen } from "react-icons/lu"; +import { LuPen, LuCopy, LuCheck } from "react-icons/lu"; +import ShareAndPrivacy from "./shareAndPrivacy"; +import { buildTranscriptWithTopics } from "./buildTranscriptWithTopics"; +import { toaster } from "../../components/ui/toaster"; type TranscriptTitle = { title: string; transcriptId: string; - onUpdate?: (newTitle: string) => void; + onUpdate: (newTitle: string) => void; + + // share props + transcript: GetTranscript | null; + topics: GetTranscriptTopic[] | null; + finalSummaryElement: HTMLDivElement | null; }; const TranscriptTitle = (props: TranscriptTitle) => { const [displayedTitle, setDisplayedTitle] = useState(props.title); const [preEditTitle, setPreEditTitle] = useState(props.title); const [isEditing, setIsEditing] = useState(false); - const api = useApi(); + const updateTranscriptMutation = useTranscriptUpdate(); + const participantsQuery = useTranscriptParticipants( + props.transcript?.id || null, + ); const updateTitle = async (newTitle: string, transcriptId: string) => { - if (!api) return; try { const requestBody: UpdateTranscript = { title: newTitle, }; - const updatedTranscript = await api?.v1TranscriptUpdate({ - transcriptId, - requestBody, + await updateTranscriptMutation.mutateAsync({ + params: { + path: { transcript_id: transcriptId }, + }, + body: requestBody, }); - if (props.onUpdate) { - props.onUpdate(newTitle); - } - console.log("Updated transcript:", updatedTranscript); + props.onUpdate(newTitle); + console.log("Updated transcript title:", newTitle); } catch (err) { console.error("Failed to update transcript:", err); + // Revert title on error + setDisplayedTitle(preEditTitle); } }; @@ -57,11 +76,11 @@ const TranscriptTitle = (props: TranscriptTitle) => { } setIsEditing(false); }; - const handleChange = (e) => { + const handleChange = (e: React.ChangeEvent) => { setDisplayedTitle(e.target.value); }; - const handleKeyDown = (e) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { updateTitle(displayedTitle, props.transcriptId); setIsEditing(false); @@ -106,6 +125,59 @@ const TranscriptTitle = (props: TranscriptTitle) => { > + {props.transcript && props.topics && ( + <> + { + const text = buildTranscriptWithTopics( + props.topics || [], + participantsQuery?.data || null, + props.transcript?.title || null, + ); + if (!text) return; + navigator.clipboard + .writeText(text) + .then(() => { + toaster + .create({ + placement: "top", + duration: 2500, + render: () => ( +
+
+ Transcript copied +
+
+ ), + }) + .then(() => {}); + }) + .catch(() => {}); + }} + > + +
+ + + )}
)} diff --git a/www/app/(app)/transcripts/useMp3.ts b/www/app/(app)/transcripts/useMp3.ts index 3e8344ad..cc0635ec 100644 --- a/www/app/(app)/transcripts/useMp3.ts +++ b/www/app/(app)/transcripts/useMp3.ts @@ -1,6 +1,7 @@ -import { useContext, useEffect, useState } from "react"; -import { DomainContext } from "../../domainContext"; -import getApi from "../../lib/useApi"; +import { useEffect, useState } from "react"; +import { useTranscriptGet } from "../../lib/apiHooks"; +import { useAuth } from "../../lib/AuthProvider"; +import { API_URL } from "../../lib/apiClient"; export type Mp3Response = { media: HTMLMediaElement | null; @@ -17,14 +18,16 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { const [audioLoadingError, setAudioLoadingError] = useState( null, ); - const [transcriptMetadataLoading, setTranscriptMetadataLoading] = - useState(true); - const [transcriptMetadataLoadingError, setTranscriptMetadataLoadingError] = - useState(null); const [audioDeleted, setAudioDeleted] = useState(null); - const api = getApi(); - const { api_url } = useContext(DomainContext); - const accessTokenInfo = api?.httpRequest?.config?.TOKEN; + const auth = useAuth(); + const accessTokenInfo = + auth.status === "authenticated" ? auth.accessToken : null; + + const { + data: transcript, + isLoading: transcriptMetadataLoading, + error: transcriptError, + } = useTranscriptGet(later ? null : transcriptId); const [serviceWorker, setServiceWorker] = useState(null); @@ -52,72 +55,50 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { }, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]); useEffect(() => { - if (!transcriptId || !api || later) return; + if (!transcriptId || later || !transcript) return; let stopped = false; let audioElement: HTMLAudioElement | null = null; let handleCanPlay: (() => void) | null = null; let handleError: (() => void) | null = null; - setTranscriptMetadataLoading(true); setAudioLoading(true); - // First fetch transcript info to check if audio is deleted - api - .v1TranscriptGet({ transcriptId }) - .then((transcript) => { - if (stopped) { - return; - } + const deleted = transcript.audio_deleted || false; + setAudioDeleted(deleted); - const deleted = transcript.audio_deleted || false; - setAudioDeleted(deleted); - setTranscriptMetadataLoadingError(null); + if (deleted) { + // Audio is deleted, don't attempt to load it + setMedia(null); + setAudioLoadingError(null); + setAudioLoading(false); + return; + } - if (deleted) { - // Audio is deleted, don't attempt to load it - setMedia(null); - setAudioLoadingError(null); - setAudioLoading(false); - return; - } + // Audio is not deleted, proceed to load it + audioElement = document.createElement("audio"); + audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`; + audioElement.crossOrigin = "anonymous"; + audioElement.preload = "auto"; - // Audio is not deleted, proceed to load it - audioElement = document.createElement("audio"); - audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`; - audioElement.crossOrigin = "anonymous"; - audioElement.preload = "auto"; + handleCanPlay = () => { + if (stopped) return; + setAudioLoading(false); + setAudioLoadingError(null); + }; - handleCanPlay = () => { - if (stopped) return; - setAudioLoading(false); - setAudioLoadingError(null); - }; + handleError = () => { + if (stopped) return; + setAudioLoading(false); + setAudioLoadingError("Failed to load audio"); + }; - handleError = () => { - if (stopped) return; - setAudioLoading(false); - setAudioLoadingError("Failed to load audio"); - }; + audioElement.addEventListener("canplay", handleCanPlay); + audioElement.addEventListener("error", handleError); - audioElement.addEventListener("canplay", handleCanPlay); - audioElement.addEventListener("error", handleError); - - if (!stopped) { - setMedia(audioElement); - } - }) - .catch((error) => { - if (stopped) return; - console.error("Failed to fetch transcript:", error); - setAudioDeleted(null); - setTranscriptMetadataLoadingError(error.message); - setAudioLoading(false); - }) - .finally(() => { - if (stopped) return; - setTranscriptMetadataLoading(false); - }); + if (!stopped) { + setMedia(audioElement); + } return () => { stopped = true; @@ -128,14 +109,18 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { if (handleError) audioElement.removeEventListener("error", handleError); } }; - }, [transcriptId, api, later, api_url]); + }, [transcriptId, transcript, later]); const getNow = () => { setLater(false); }; const loading = audioLoading || transcriptMetadataLoading; - const error = audioLoadingError || transcriptMetadataLoadingError; + const error = + audioLoadingError || + (transcriptError + ? (transcriptError as any).message || String(transcriptError) + : null); return { media, loading, error, getNow, audioDeleted }; }; diff --git a/www/app/(app)/transcripts/useParticipants.ts b/www/app/(app)/transcripts/useParticipants.ts index 38f5aa35..a3674597 100644 --- a/www/app/(app)/transcripts/useParticipants.ts +++ b/www/app/(app)/transcripts/useParticipants.ts @@ -1,8 +1,6 @@ -import { useEffect, useState } from "react"; -import { Participant } from "../../api"; -import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { shouldShowError } from "../../lib/errorUtils"; +import type { components } from "../../reflector-api"; +type Participant = components["schemas"]["Participant"]; +import { useTranscriptParticipants } from "../../lib/apiHooks"; type ErrorParticipants = { error: Error; @@ -29,46 +27,38 @@ export type UseParticipants = ( ) & { refetch: () => void }; const useParticipants = (transcriptId: string): UseParticipants => { - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); - const [count, setCount] = useState(0); + const { + data: response, + isLoading: loading, + error, + refetch, + } = useTranscriptParticipants(transcriptId || null); - const refetch = () => { - if (!loading) { - setCount(count + 1); - setLoading(true); - setErrorState(null); - } - }; + // Type-safe return based on state + if (error) { + return { + error: error as Error, + loading: false, + response: null, + refetch, + } satisfies ErrorParticipants & { refetch: () => void }; + } - useEffect(() => { - if (!transcriptId || !api) return; + if (loading || !response) { + return { + response: response || null, + loading: true, + error: null, + refetch, + } satisfies LoadingParticipants & { refetch: () => void }; + } - setLoading(true); - api - .v1TranscriptGetParticipants({ transcriptId }) - .then((result) => { - setResponse(result); - setLoading(false); - console.debug("Participants Loaded:", result); - }) - .catch((error) => { - const shouldShowHuman = shouldShowError(error); - if (shouldShowHuman) { - setError(error, "There was an error loading the participants"); - } else { - setError(error); - } - setErrorState(error); - setResponse(null); - setLoading(false); - }); - }, [transcriptId, !api, count]); - - return { response, loading, error, refetch } as UseParticipants; + return { + response, + loading: false, + error: null, + refetch, + } satisfies SuccessParticipants & { refetch: () => void }; }; export default useParticipants; diff --git a/www/app/(app)/transcripts/useSearchTranscripts.ts b/www/app/(app)/transcripts/useSearchTranscripts.ts deleted file mode 100644 index 2e6a7311..00000000 --- a/www/app/(app)/transcripts/useSearchTranscripts.ts +++ /dev/null @@ -1,123 +0,0 @@ -// this hook is not great, we want to substitute it with a proper state management solution that is also not re-invention - -import { useEffect, useRef, useState } from "react"; -import { SearchResult, SourceKind } from "../../api"; -import useApi from "../../lib/useApi"; -import { - PaginationPage, - paginationPageTo0Based, -} from "../browse/_components/Pagination"; - -interface SearchFilters { - roomIds: readonly string[] | null; - sourceKind: SourceKind | null; -} - -const EMPTY_SEARCH_FILTERS: SearchFilters = { - roomIds: null, - sourceKind: null, -}; - -type UseSearchTranscriptsOptions = { - pageSize: number; - page: PaginationPage; -}; - -interface UseSearchTranscriptsReturn { - results: SearchResult[]; - totalCount: number; - isLoading: boolean; - error: unknown; - reload: () => void; -} - -function hashEffectFilters(filters: SearchFilters): string { - return JSON.stringify(filters); -} - -export function useSearchTranscripts( - query: string = "", - filters: SearchFilters = EMPTY_SEARCH_FILTERS, - options: UseSearchTranscriptsOptions = { - pageSize: 20, - page: PaginationPage(1), - }, -): UseSearchTranscriptsReturn { - const { pageSize, page } = options; - - const [reloadCount, setReloadCount] = useState(0); - - const api = useApi(); - const abortControllerRef = useRef(); - - const [data, setData] = useState<{ results: SearchResult[]; total: number }>({ - results: [], - total: 0, - }); - const [error, setError] = useState(); - const [isLoading, setIsLoading] = useState(false); - - const filterHash = hashEffectFilters(filters); - - useEffect(() => { - if (!api) { - setData({ results: [], total: 0 }); - setError(undefined); - setIsLoading(false); - return; - } - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - const performSearch = async () => { - setIsLoading(true); - - try { - const response = await api.v1TranscriptsSearch({ - q: query || "", - limit: pageSize, - offset: paginationPageTo0Based(page) * pageSize, - roomId: filters.roomIds?.[0], - sourceKind: filters.sourceKind || undefined, - }); - - if (abortController.signal.aborted) return; - setData(response); - setError(undefined); - } catch (err: unknown) { - if ((err as Error).name === "AbortError") { - return; - } - if (abortController.signal.aborted) { - console.error("Aborted search but error", err); - return; - } - - setError(err); - } finally { - if (!abortController.signal.aborted) { - setIsLoading(false); - } - } - }; - - performSearch().then(() => {}); - - return () => { - abortController.abort(); - }; - }, [api, query, page, filterHash, pageSize, reloadCount]); - - return { - results: data.results, - totalCount: data.total, - isLoading, - error, - reload: () => setReloadCount(reloadCount + 1), - }; -} diff --git a/www/app/(app)/transcripts/useTopicWithWords.ts b/www/app/(app)/transcripts/useTopicWithWords.ts index 29d0b982..31e184cc 100644 --- a/www/app/(app)/transcripts/useTopicWithWords.ts +++ b/www/app/(app)/transcripts/useTopicWithWords.ts @@ -1,9 +1,8 @@ -import { useEffect, useState } from "react"; +import type { components } from "../../reflector-api"; +import { useTranscriptTopicsWithWordsPerSpeaker } from "../../lib/apiHooks"; -import { GetTranscriptTopicWithWordsPerSpeaker } from "../../api"; -import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { shouldShowError } from "../../lib/errorUtils"; +type GetTranscriptTopicWithWordsPerSpeaker = + components["schemas"]["GetTranscriptTopicWithWordsPerSpeaker"]; type ErrorTopicWithWords = { error: Error; @@ -33,47 +32,40 @@ const useTopicWithWords = ( topicId: string | undefined, transcriptId: string, ): UseTopicWithWords => { - const [response, setResponse] = - useState(null); - const [loading, setLoading] = useState(false); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); + const { + data: response, + isLoading: loading, + error, + refetch, + } = useTranscriptTopicsWithWordsPerSpeaker( + transcriptId || null, + topicId || null, + ); - const [count, setCount] = useState(0); + if (error) { + return { + error: error as Error, + loading: false, + response: null, + refetch, + } satisfies ErrorTopicWithWords & { refetch: () => void }; + } - const refetch = () => { - if (!loading) { - setCount(count + 1); - setLoading(true); - setErrorState(null); - } - }; + if (loading || !response) { + return { + response: response || null, + loading: true, + error: false, + refetch, + } satisfies LoadingTopicWithWords & { refetch: () => void }; + } - useEffect(() => { - if (!transcriptId || !topicId || !api) return; - - setLoading(true); - - api - .v1TranscriptGetTopicsWithWordsPerSpeaker({ transcriptId, topicId }) - .then((result) => { - setResponse(result); - setLoading(false); - console.debug("Topics with words Loaded:", result); - }) - .catch((error) => { - const shouldShowHuman = shouldShowError(error); - if (shouldShowHuman) { - setError(error, "There was an error loading the topics with words"); - } else { - setError(error); - } - setErrorState(error); - }); - }, [transcriptId, !api, topicId, count]); - - return { response, loading, error, refetch } as UseTopicWithWords; + return { + response, + loading: false, + error: null, + refetch, + } satisfies SuccessTopicWithWords & { refetch: () => void }; }; export default useTopicWithWords; diff --git a/www/app/(app)/transcripts/useTopics.ts b/www/app/(app)/transcripts/useTopics.ts index ff17beaf..7f337582 100644 --- a/www/app/(app)/transcripts/useTopics.ts +++ b/www/app/(app)/transcripts/useTopics.ts @@ -1,10 +1,7 @@ -import { useEffect, useState } from "react"; +import { useTranscriptTopics } from "../../lib/apiHooks"; +import type { components } from "../../reflector-api"; -import { useError } from "../../(errors)/errorContext"; -import { Topic } from "./webSocketTypes"; -import useApi from "../../lib/useApi"; -import { shouldShowError } from "../../lib/errorUtils"; -import { GetTranscriptTopic } from "../../api"; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; type TranscriptTopics = { topics: GetTranscriptTopic[] | null; @@ -13,34 +10,13 @@ type TranscriptTopics = { }; const useTopics = (id: string): TranscriptTopics => { - const [topics, setTopics] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); - useEffect(() => { - if (!id || !api) return; + const { data: topics, isLoading: loading, error } = useTranscriptTopics(id); - setLoading(true); - api - .v1TranscriptGetTopics({ transcriptId: id }) - .then((result) => { - setTopics(result); - setLoading(false); - console.debug("Transcript topics loaded:", result); - }) - .catch((err) => { - setErrorState(err); - const shouldShowHuman = shouldShowError(err); - if (shouldShowHuman) { - setError(err, "There was an error loading the topics"); - } else { - setError(err); - } - }); - }, [id, !api]); - - return { topics, loading, error }; + return { + topics: topics || null, + loading, + error: error as Error | null, + }; }; export default useTopics; diff --git a/www/app/(app)/transcripts/useTranscript.ts b/www/app/(app)/transcripts/useTranscript.ts deleted file mode 100644 index 49d257f0..00000000 --- a/www/app/(app)/transcripts/useTranscript.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useState } from "react"; -import { GetTranscript } from "../../api"; -import { useError } from "../../(errors)/errorContext"; -import { shouldShowError } from "../../lib/errorUtils"; -import useApi from "../../lib/useApi"; - -type ErrorTranscript = { - error: Error; - loading: false; - response: null; - reload: () => void; -}; - -type LoadingTranscript = { - response: null; - loading: true; - error: false; - reload: () => void; -}; - -type SuccessTranscript = { - response: GetTranscript; - loading: false; - error: null; - reload: () => void; -}; - -const useTranscript = ( - id: string | null, -): ErrorTranscript | LoadingTranscript | SuccessTranscript => { - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); - const [reload, setReload] = useState(0); - const { setError } = useError(); - const api = useApi(); - const reloadHandler = () => setReload((prev) => prev + 1); - - useEffect(() => { - if (!id || !api) return; - - if (!response) { - setLoading(true); - } - - api - .v1TranscriptGet({ transcriptId: id }) - .then((result) => { - setResponse(result); - setLoading(false); - console.debug("Transcript Loaded:", result); - }) - .catch((error) => { - const shouldShowHuman = shouldShowError(error); - if (shouldShowHuman) { - setError(error, "There was an error loading the transcript"); - } else { - setError(error); - } - setErrorState(error); - }); - }, [id, !api, reload]); - - return { response, loading, error, reload: reloadHandler } as - | ErrorTranscript - | LoadingTranscript - | SuccessTranscript; -}; - -export default useTranscript; diff --git a/www/app/(app)/transcripts/useWaveform.ts b/www/app/(app)/transcripts/useWaveform.ts index 19b2a265..8bb8c4c9 100644 --- a/www/app/(app)/transcripts/useWaveform.ts +++ b/www/app/(app)/transcripts/useWaveform.ts @@ -1,8 +1,7 @@ -import { useEffect, useState } from "react"; -import { AudioWaveform } from "../../api"; -import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { shouldShowError } from "../../lib/errorUtils"; +import type { components } from "../../reflector-api"; +import { useTranscriptWaveform } from "../../lib/apiHooks"; + +type AudioWaveform = components["schemas"]["AudioWaveform"]; type AudioWaveFormResponse = { waveform: AudioWaveform | null; @@ -11,35 +10,17 @@ type AudioWaveFormResponse = { }; const useWaveform = (id: string, skip: boolean): AudioWaveFormResponse => { - const [waveform, setWaveform] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); + const { + data: waveform, + isLoading: loading, + error, + } = useTranscriptWaveform(skip ? null : id); - useEffect(() => { - if (!id || !api || skip) { - setLoading(false); - setErrorState(null); - setWaveform(null); - return; - } - setLoading(true); - setErrorState(null); - api - .v1TranscriptGetAudioWaveform({ transcriptId: id }) - .then((result) => { - setWaveform(result); - setLoading(false); - console.debug("Transcript waveform loaded:", result); - }) - .catch((err) => { - setErrorState(err); - setLoading(false); - }); - }, [id, api, skip]); - - return { waveform, loading, error }; + return { + waveform: waveform || null, + loading, + error: error as Error | null, + }; }; export default useWaveform; diff --git a/www/app/(app)/transcripts/useWebRTC.ts b/www/app/(app)/transcripts/useWebRTC.ts index c8370aa4..89a2a946 100644 --- a/www/app/(app)/transcripts/useWebRTC.ts +++ b/www/app/(app)/transcripts/useWebRTC.ts @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import Peer from "simple-peer"; import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { RtcOffer } from "../../api"; +import { useTranscriptWebRTC } from "../../lib/apiHooks"; +import type { components } from "../../reflector-api"; +type RtcOffer = components["schemas"]["RtcOffer"]; const useWebRTC = ( stream: MediaStream | null, @@ -10,10 +11,10 @@ const useWebRTC = ( ): Peer => { const [peer, setPeer] = useState(null); const { setError } = useError(); - const api = useApi(); + const { mutateAsync: mutateWebRtcTranscriptAsync } = useTranscriptWebRTC(); useEffect(() => { - if (!stream || !transcriptId || !api) { + if (!stream || !transcriptId) { return; } @@ -24,7 +25,7 @@ const useWebRTC = ( try { p = new Peer({ initiator: true, stream: stream }); } catch (error) { - setError(error, "Error creating WebRTC"); + setError(error as Error, "Error creating WebRTC"); return; } @@ -32,26 +33,31 @@ const useWebRTC = ( setError(new Error(`WebRTC error: ${err}`)); }); - p.on("signal", (data: any) => { - if (!api) return; + p.on("signal", async (data: any) => { if ("sdp" in data) { const rtcOffer: RtcOffer = { sdp: data.sdp, type: data.type, }; - api - .v1TranscriptRecordWebrtc({ transcriptId, requestBody: rtcOffer }) - .then((answer) => { - try { - p.signal(answer); - } catch (error) { - setError(error); - } - }) - .catch((error) => { - setError(error, "Error loading WebRTCOffer"); + try { + const answer = await mutateWebRtcTranscriptAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: rtcOffer, }); + + try { + p.signal(answer); + } catch (error) { + setError(error as Error); + } + } catch (error) { + setError(error as Error, "Error loading WebRTCOffer"); + } } }); @@ -63,7 +69,7 @@ const useWebRTC = ( return () => { p.destroy(); }; - }, [stream, transcriptId, !api]); + }, [stream, transcriptId, mutateWebRtcTranscriptAsync]); return peer; }; diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts index 6fa5edc7..ed44577e 100644 --- a/www/app/(app)/transcripts/useWebSockets.ts +++ b/www/app/(app)/transcripts/useWebSockets.ts @@ -1,9 +1,12 @@ -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Topic, FinalSummary, Status } from "./webSocketTypes"; import { useError } from "../../(errors)/errorContext"; -import { DomainContext } from "../../domainContext"; -import { AudioWaveform, GetTranscriptSegmentTopic } from "../../api"; -import useApi from "../../lib/useApi"; +import type { components } from "../../reflector-api"; +type AudioWaveform = components["schemas"]["AudioWaveform"]; +type GetTranscriptSegmentTopic = + components["schemas"]["GetTranscriptSegmentTopic"]; +import { useQueryClient } from "@tanstack/react-query"; +import { $api, WEBSOCKET_URL } from "../../lib/apiClient"; export type UseWebSockets = { transcriptTextLive: string; @@ -12,7 +15,7 @@ export type UseWebSockets = { title: string; topics: Topic[]; finalSummary: FinalSummary; - status: Status; + status: Status | null; waveform: AudioWaveform | null; duration: number | null; }; @@ -30,11 +33,10 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { const [finalSummary, setFinalSummary] = useState({ summary: "", }); - const [status, setStatus] = useState({ value: "" }); + const [status, setStatus] = useState(null); const { setError } = useError(); - const { websocket_url } = useContext(DomainContext); - const api = useApi(); + const queryClient = useQueryClient(); const [accumulatedText, setAccumulatedText] = useState(""); @@ -60,7 +62,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { useEffect(() => { document.onkeyup = (e) => { - if (e.key === "a" && process.env.NEXT_PUBLIC_ENV === "development") { + if (e.key === "a" && process.env.NODE_ENV === "development") { const segments: GetTranscriptSegmentTopic[] = [ { speaker: 1, @@ -105,6 +107,13 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { title: "Topic 1: Introduction to Quantum Mechanics", transcript: "A brief overview of quantum mechanics and its principles.", + segments: [ + { + speaker: 1, + start: 0, + text: "This is the transcription of an example title", + }, + ], }, { id: "2", @@ -192,7 +201,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { setFinalSummary({ summary: "This is the final summary" }); } - if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") { + if (e.key === "z" && process.env.NODE_ENV === "development") { setTranscriptTextLive( "This text is in English, and it is a pretty long sentence to test the limits", ); @@ -315,11 +324,9 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { } }; - if (!transcriptId || !api) return; + if (!transcriptId) return; - api?.v1TranscriptGetWebsocketEvents({ transcriptId }).then((result) => {}); - - const url = `${websocket_url}/v1/transcripts/${transcriptId}/events`; + const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`; let ws = new WebSocket(url); ws.onopen = () => { @@ -361,6 +368,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { return [...prevTopics, topic]; }); console.debug("TOPIC event:", message.data); + // Invalidate topics query to sync with WebSocket data + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/topics", + { + params: { path: { transcript_id: transcriptId } }, + }, + ).queryKey, + }); break; case "FINAL_SHORT_SUMMARY": @@ -370,6 +387,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { case "FINAL_LONG_SUMMARY": if (message.data) { setFinalSummary(message.data); + // Invalidate transcript query to sync summary + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { path: { transcript_id: transcriptId } }, + }, + ).queryKey, + }); } break; @@ -377,6 +404,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { console.debug("FINAL_TITLE event:", message.data); if (message.data) { setTitle(message.data.title); + // Invalidate transcript query to sync title + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { path: { transcript_id: transcriptId } }, + }, + ).queryKey, + }); } break; @@ -434,6 +471,11 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { break; case 1001: // Navigate away break; + case 1006: // Closed by client Chrome + console.warn( + "WebSocket closed by client, likely duplicated connection in react dev mode", + ); + break; default: setError( new Error(`WebSocket closed unexpectedly with code: ${event.code}`), @@ -450,7 +492,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { return () => { ws.close(); }; - }, [transcriptId, !api]); + }, [transcriptId]); return { transcriptTextLive, diff --git a/www/app/(app)/transcripts/webSocketTypes.ts b/www/app/(app)/transcripts/webSocketTypes.ts index edd35eb6..5422cc24 100644 --- a/www/app/(app)/transcripts/webSocketTypes.ts +++ b/www/app/(app)/transcripts/webSocketTypes.ts @@ -1,4 +1,7 @@ -import { GetTranscriptTopic } from "../../api"; +import type { components } from "../../reflector-api"; +import type { TranscriptStatus } from "../../lib/transcript"; + +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; export type Topic = GetTranscriptTopic; @@ -11,7 +14,7 @@ export type FinalSummary = { }; export type Status = { - value: string; + value: TranscriptStatus; }; export type TranslatedTopic = { diff --git a/www/app/(auth)/userInfo.tsx b/www/app/(auth)/userInfo.tsx index ffb286b3..bf6a5b62 100644 --- a/www/app/(auth)/userInfo.tsx +++ b/www/app/(auth)/userInfo.tsx @@ -1,18 +1,21 @@ "use client"; -import { signOut, signIn } from "next-auth/react"; -import useSessionStatus from "../lib/useSessionStatus"; + import { Spinner, Link } from "@chakra-ui/react"; +import { useAuth } from "../lib/AuthProvider"; export default function UserInfo() { - const { isLoading, isAuthenticated } = useSessionStatus(); - + const auth = useAuth(); + const status = auth.status; + const isLoading = status === "loading"; + const isAuthenticated = status === "authenticated"; + const isRefreshing = status === "refreshing"; return isLoading ? ( - ) : !isAuthenticated ? ( + ) : !isAuthenticated && !isRefreshing ? ( signIn("authentik")} + onClick={() => auth.signIn("authentik")} > Log in @@ -20,7 +23,7 @@ export default function UserInfo() { signOut({ callbackUrl: "/" })} + onClick={() => auth.signOut({ callbackUrl: "/" })} > Log out diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx new file mode 100644 index 00000000..2780acbd --- /dev/null +++ b/www/app/[roomName]/MeetingSelection.tsx @@ -0,0 +1,569 @@ +"use client"; + +import { partition } from "remeda"; +import { + Box, + VStack, + HStack, + Text, + Button, + Spinner, + Badge, + Icon, + Flex, +} from "@chakra-ui/react"; +import React from "react"; +import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa"; +import { LuX } from "react-icons/lu"; +import type { components } from "../reflector-api"; +import { + useRoomActiveMeetings, + useRoomJoinMeeting, + useMeetingDeactivate, + useRoomGetByName, +} from "../lib/apiHooks"; +import { useRouter } from "next/navigation"; +import { formatDateTime, formatStartedAgo } from "../lib/timeUtils"; +import MeetingMinimalHeader from "../components/MeetingMinimalHeader"; +import { NonEmptyString } from "../lib/utils"; + +type Meeting = components["schemas"]["Meeting"]; + +interface MeetingSelectionProps { + roomName: NonEmptyString; + isOwner: boolean; + isSharedRoom: boolean; + authLoading: boolean; + onMeetingSelect: (meeting: Meeting) => void; + onCreateUnscheduled: () => void; + isCreatingMeeting?: boolean; +} + +export default function MeetingSelection({ + roomName, + isOwner, + isSharedRoom, + onMeetingSelect, + onCreateUnscheduled, + isCreatingMeeting = false, +}: MeetingSelectionProps) { + const router = useRouter(); + const roomQuery = useRoomGetByName(roomName); + const activeMeetingsQuery = useRoomActiveMeetings(roomName); + const joinMeetingMutation = useRoomJoinMeeting(); + const deactivateMeetingMutation = useMeetingDeactivate(); + + const room = roomQuery.data; + const allMeetings = activeMeetingsQuery.data || []; + + const now = new Date(); + const [currentMeetings, nonCurrentMeetings] = partition( + allMeetings, + (meeting) => { + const startTime = new Date(meeting.start_date); + const endTime = new Date(meeting.end_date); + // Meeting is ongoing if current time is between start and end + return now >= startTime && now <= endTime; + }, + ); + + const upcomingMeetings = nonCurrentMeetings.filter((meeting) => { + const startTime = new Date(meeting.start_date); + // Meeting is upcoming if it hasn't started yet + return now < startTime; + }); + + const loading = roomQuery.isLoading || activeMeetingsQuery.isLoading; + const error = roomQuery.error || activeMeetingsQuery.error; + + const handleJoinUpcoming = async (meeting: Meeting) => { + // Join the upcoming meeting and navigate to local meeting page + try { + const joinedMeeting = await joinMeetingMutation.mutateAsync({ + params: { + path: { + room_name: roomName, + meeting_id: meeting.id, + }, + }, + }); + onMeetingSelect(joinedMeeting); + } catch (err) { + console.error("Failed to join upcoming meeting:", err); + } + }; + + const handleJoinDirect = (meeting: Meeting) => { + // Navigate to local meeting page instead of external URL + onMeetingSelect(meeting); + }; + + const handleEndMeeting = async (meetingId: string) => { + try { + await deactivateMeetingMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + }); + } catch (err) { + console.error("Failed to end meeting:", err); + } + }; + + if (loading) { + return ( + + + Loading meetings... + + ); + } + + if (error) { + return ( + + + Error + + {"Failed to load meetings"} + + ); + } + + const handleLeaveMeeting = () => { + router.push("/"); + }; + + return ( + + {/* Loading overlay */} + {isCreatingMeeting && ( + + + + + Creating meeting... + + + + )} + + + + + {/* Current Ongoing Meetings - BIG DISPLAY */} + {currentMeetings.length > 0 ? ( + + {currentMeetings.map((meeting) => ( + + + + + + + {(meeting.calendar_metadata as any)?.title || + "Live Meeting"} + + + + {isOwner && + (meeting.calendar_metadata as any)?.description && ( + + {(meeting.calendar_metadata as any).description} + + )} + + + + + + {meeting.num_clients || 0} participant + {meeting.num_clients !== 1 ? "s" : ""} + + + + + + {formatStartedAgo(new Date(meeting.start_date))} + + + + + {isOwner && + (meeting.calendar_metadata as any)?.attendees && ( + + {(meeting.calendar_metadata as any).attendees + .slice(0, 4) + .map((attendee: any, idx: number) => ( + + {attendee.name || attendee.email} + + ))} + {(meeting.calendar_metadata as any).attendees.length > + 4 && ( + + + + {(meeting.calendar_metadata as any).attendees + .length - 4}{" "} + more + + )} + + )} + + + + + {isOwner && ( + + )} + + + + ))} + + ) : upcomingMeetings.length > 0 ? ( + /* Upcoming Meetings - BIG DISPLAY when no ongoing meetings */ + + + Upcoming Meeting{upcomingMeetings.length > 1 ? "s" : ""} + + {upcomingMeetings.map((meeting) => { + const now = new Date(); + const startTime = new Date(meeting.start_date); + const minutesUntilStart = Math.floor( + (startTime.getTime() - now.getTime()) / (1000 * 60), + ); + + return ( + + + + + + + {(meeting.calendar_metadata as any)?.title || + "Upcoming Meeting"} + + + + {isOwner && + (meeting.calendar_metadata as any)?.description && ( + + {(meeting.calendar_metadata as any).description} + + )} + + + + Starts in {minutesUntilStart} minute + {minutesUntilStart !== 1 ? "s" : ""} + + + {formatDateTime(new Date(meeting.start_date))} + + + + {isOwner && + (meeting.calendar_metadata as any)?.attendees && ( + + {(meeting.calendar_metadata as any).attendees + .slice(0, 4) + .map((attendee: any, idx: number) => ( + + {attendee.name || attendee.email} + + ))} + {(meeting.calendar_metadata as any).attendees + .length > 4 && ( + + + + {(meeting.calendar_metadata as any).attendees + .length - 4}{" "} + more + + )} + + )} + + + + + {isOwner && ( + + )} + + + + ); + })} + + ) : null} + + {/* Upcoming Meetings - SMALLER ASIDE DISPLAY when there are ongoing meetings */} + {currentMeetings.length > 0 && upcomingMeetings.length > 0 && ( + + + Starting Soon + + + {upcomingMeetings.map((meeting) => { + const now = new Date(); + const startTime = new Date(meeting.start_date); + const minutesUntilStart = Math.floor( + (startTime.getTime() - now.getTime()) / (1000 * 60), + ); + + return ( + + + + + + {(meeting.calendar_metadata as any)?.title || + "Upcoming Meeting"} + + + + + in {minutesUntilStart} minute + {minutesUntilStart !== 1 ? "s" : ""} + + + + Starts: {formatDateTime(new Date(meeting.start_date))} + + + + + + ); + })} + + + )} + + {/* No meetings message - show when no ongoing or upcoming meetings */} + {currentMeetings.length === 0 && upcomingMeetings.length === 0 && ( + + + + + + No meetings right now + + + There are no ongoing or upcoming meetings in this room at the + moment. + + + + + )} + + + ); +} diff --git a/www/app/[roomName]/[meetingId]/constants.ts b/www/app/[roomName]/[meetingId]/constants.ts new file mode 100644 index 00000000..6978da36 --- /dev/null +++ b/www/app/[roomName]/[meetingId]/constants.ts @@ -0,0 +1 @@ +export const MEETING_DEFAULT_TIME_MINUTES = 60; diff --git a/www/app/[roomName]/[meetingId]/page.tsx b/www/app/[roomName]/[meetingId]/page.tsx new file mode 100644 index 00000000..725aa571 --- /dev/null +++ b/www/app/[roomName]/[meetingId]/page.tsx @@ -0,0 +1,3 @@ +import RoomContainer from "../components/RoomContainer"; + +export default RoomContainer; diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx new file mode 100644 index 00000000..cfefbf6a --- /dev/null +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { Box } from "@chakra-ui/react"; +import { useRouter } from "next/navigation"; +import DailyIframe, { DailyCall } from "@daily-co/daily-js"; +import type { components } from "../../reflector-api"; +import { useAuth } from "../../lib/AuthProvider"; +import { + ConsentDialogButton, + recordingTypeRequiresConsent, +} from "../../lib/consent"; + +type Meeting = components["schemas"]["Meeting"]; + +interface DailyRoomProps { + meeting: Meeting; +} + +export default function DailyRoom({ meeting }: DailyRoomProps) { + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const containerRef = useRef(null); + + const roomUrl = meeting?.host_room_url || meeting?.room_url; + + const isLoading = status === "loading"; + + const handleLeave = useCallback(() => { + router.push("/browse"); + }, [router]); + + useEffect(() => { + if (isLoading || !roomUrl || !containerRef.current) return; + + let frame: DailyCall | null = null; + let destroyed = false; + + const createAndJoin = async () => { + try { + const existingFrame = DailyIframe.getCallInstance(); + if (existingFrame) { + await existingFrame.destroy(); + } + + frame = DailyIframe.createFrame(containerRef.current!, { + iframeStyle: { + width: "100vw", + height: "100vh", + border: "none", + }, + showLeaveButton: true, + showFullscreenButton: true, + }); + + if (destroyed) { + await frame.destroy(); + return; + } + + frame.on("left-meeting", handleLeave); + + frame.on("joined-meeting", async () => { + try { + await frame.startRecording({ type: "raw-tracks" }); + } catch (error) { + console.error("Failed to start recording:", error); + } + }); + + await frame.join({ url: roomUrl }); + } catch (error) { + console.error("Error creating Daily frame:", error); + } + }; + + createAndJoin(); + + return () => { + destroyed = true; + if (frame) { + frame.destroy().catch((e) => { + console.error("Error destroying frame:", e); + }); + } + }; + }, [roomUrl, isLoading, handleLeave]); + + if (!roomUrl) { + return null; + } + + return ( + +
+ {meeting.recording_type && + recordingTypeRequiresConsent(meeting.recording_type) && + meeting.id && } + + ); +} diff --git a/www/app/[roomName]/components/RoomContainer.tsx b/www/app/[roomName]/components/RoomContainer.tsx new file mode 100644 index 00000000..bfcd82f7 --- /dev/null +++ b/www/app/[roomName]/components/RoomContainer.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { roomMeetingUrl } from "../../lib/routes"; +import { useCallback, useEffect, useState, use } from "react"; +import { Box, Text, Spinner } from "@chakra-ui/react"; +import { useRouter } from "next/navigation"; +import { + useRoomGetByName, + useRoomsCreateMeeting, + useRoomGetMeeting, +} from "../../lib/apiHooks"; +import type { components } from "../../reflector-api"; +import MeetingSelection from "../MeetingSelection"; +import useRoomDefaultMeeting from "../useRoomDefaultMeeting"; +import WherebyRoom from "./WherebyRoom"; +import DailyRoom from "./DailyRoom"; +import { useAuth } from "../../lib/AuthProvider"; +import { useError } from "../../(errors)/errorContext"; +import { parseNonEmptyString } from "../../lib/utils"; +import { printApiError } from "../../api/_error"; + +type Meeting = components["schemas"]["Meeting"]; + +export type RoomDetails = { + params: Promise<{ + roomName: string; + meetingId?: string; + }>; +}; + +function LoadingSpinner() { + return ( + + + + ); +} + +export default function RoomContainer(details: RoomDetails) { + const params = use(details.params); + const roomName = parseNonEmptyString( + params.roomName, + true, + "panic! params.roomName is required", + ); + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + const { setError } = useError(); + + const roomQuery = useRoomGetByName(roomName); + const createMeetingMutation = useRoomsCreateMeeting(); + + const room = roomQuery.data; + + const pageMeetingId = params.meetingId; + + const defaultMeeting = useRoomDefaultMeeting( + room && !room.ics_enabled && !pageMeetingId ? roomName : null, + ); + + const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null); + + const meeting = explicitMeeting.data || defaultMeeting.response; + + const isLoading = + status === "loading" || + roomQuery.isLoading || + defaultMeeting?.loading || + explicitMeeting.isLoading || + createMeetingMutation.isPending; + + const errors = [ + explicitMeeting.error, + defaultMeeting.error, + roomQuery.error, + createMeetingMutation.error, + ].filter(Boolean); + + const isOwner = + isAuthenticated && room ? auth.user?.id === room.user_id : false; + + const handleMeetingSelect = (selectedMeeting: Meeting) => { + router.push( + roomMeetingUrl( + roomName, + parseNonEmptyString( + selectedMeeting.id, + true, + "panic! selectedMeeting.id is required", + ), + ), + ); + }; + + const handleCreateUnscheduled = async () => { + try { + const newMeeting = await createMeetingMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + body: { + allow_duplicated: room ? room.ics_enabled : false, + }, + }); + handleMeetingSelect(newMeeting); + } catch (err) { + console.error("Failed to create meeting:", err); + } + }; + + if (isLoading) { + return ; + } + + if (!room) { + return ( + + Room not found + + ); + } + + if (room.ics_enabled && !params.meetingId) { + return ( + + ); + } + + if (errors.length > 0) { + return ( + + {errors.map((error, i) => ( + + {printApiError(error)} + + ))} + + ); + } + + if (!meeting) { + return ; + } + + const platform = meeting.platform; + + if (!platform) { + return ( + + Meeting platform not configured + + ); + } + + switch (platform) { + case "daily": + return ; + case "whereby": + return ; + default: { + const _exhaustive: never = platform; + return ( + + Unknown platform: {platform} + + ); + } + } +} diff --git a/www/app/[roomName]/components/WherebyRoom.tsx b/www/app/[roomName]/components/WherebyRoom.tsx new file mode 100644 index 00000000..d670b4e2 --- /dev/null +++ b/www/app/[roomName]/components/WherebyRoom.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useCallback, useEffect, useRef, RefObject } from "react"; +import { useRouter } from "next/navigation"; +import type { components } from "../../reflector-api"; +import { useAuth } from "../../lib/AuthProvider"; +import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient"; +import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils"; +import { + ConsentDialogButton as BaseConsentDialogButton, + useConsentDialog, + recordingTypeRequiresConsent, +} from "../../lib/consent"; + +type Meeting = components["schemas"]["Meeting"]; + +interface WherebyRoomProps { + meeting: Meeting; +} + +function WherebyConsentDialogButton({ + meetingId, + wherebyRef, +}: { + meetingId: NonEmptyString; + wherebyRef: React.RefObject; +}) { + const previousFocusRef = useRef(null); + + useEffect(() => { + const element = wherebyRef.current; + if (!element) return; + + const handleWherebyReady = () => { + previousFocusRef.current = document.activeElement as HTMLElement; + }; + + element.addEventListener("ready", handleWherebyReady); + + return () => { + element.removeEventListener("ready", handleWherebyReady); + if (previousFocusRef.current && document.activeElement === element) { + previousFocusRef.current.focus(); + } + }; + }, [wherebyRef]); + + return ; +} + +export default function WherebyRoom({ meeting }: WherebyRoomProps) { + const wherebyLoaded = useWhereby(); + const wherebyRef = useRef(null); + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + + const wherebyRoomUrl = getWherebyUrl(meeting); + const recordingType = meeting.recording_type; + const meetingId = meeting.id; + + const isLoading = status === "loading"; + + const handleLeave = useCallback(() => { + router.push("/browse"); + }, [router]); + + useEffect(() => { + if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded) + return; + + wherebyRef.current?.addEventListener("leave", handleLeave); + + return () => { + wherebyRef.current?.removeEventListener("leave", handleLeave); + }; + }, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]); + + if (!wherebyRoomUrl || !wherebyLoaded) { + return null; + } + + return ( + <> + + {recordingType && + recordingTypeRequiresConsent(recordingType) && + meetingId && ( + + )} + + ); +} diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index b03a7e4f..87651a50 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -1,326 +1,3 @@ -"use client"; +import RoomContainer from "./components/RoomContainer"; -import { - useCallback, - useEffect, - useRef, - useState, - useContext, - RefObject, -} from "react"; -import { - Box, - Button, - Text, - VStack, - HStack, - Spinner, - Icon, -} from "@chakra-ui/react"; -import { toaster } from "../components/ui/toaster"; -import useRoomMeeting from "./useRoomMeeting"; -import { useRouter } from "next/navigation"; -import { notFound } from "next/navigation"; -import useSessionStatus from "../lib/useSessionStatus"; -import { useRecordingConsent } from "../recordingConsentContext"; -import useApi from "../lib/useApi"; -import { Meeting } from "../api"; -import { FaBars } from "react-icons/fa6"; - -export type RoomDetails = { - params: { - roomName: string; - }; -}; - -// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially -const useConsentWherebyFocusManagement = ( - acceptButtonRef: RefObject, - wherebyRef: RefObject, -) => { - const currentFocusRef = useRef(null); - useEffect(() => { - if (acceptButtonRef.current) { - acceptButtonRef.current.focus(); - } else { - console.error( - "accept button ref not available yet for focus management - seems to be illegal state", - ); - } - - const handleWherebyReady = () => { - console.log("whereby ready - refocusing consent button"); - currentFocusRef.current = document.activeElement as HTMLElement; - if (acceptButtonRef.current) { - acceptButtonRef.current.focus(); - } - }; - - if (wherebyRef.current) { - wherebyRef.current.addEventListener("ready", handleWherebyReady); - } else { - console.warn( - "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.", - ); - } - - return () => { - wherebyRef.current?.removeEventListener("ready", handleWherebyReady); - currentFocusRef.current?.focus(); - }; - }, []); -}; - -const useConsentDialog = ( - meetingId: string, - wherebyRef: RefObject /*accessibility*/, -) => { - const { state: consentState, touch, hasConsent } = useRecordingConsent(); - const [consentLoading, setConsentLoading] = useState(false); - // toast would open duplicates, even with using "id=" prop - const [modalOpen, setModalOpen] = useState(false); - const api = useApi(); - - const handleConsent = useCallback( - async (meetingId: string, given: boolean) => { - if (!api) return; - - setConsentLoading(true); - - try { - await api.v1MeetingAudioConsent({ - meetingId, - requestBody: { consent_given: given }, - }); - - touch(meetingId); - } catch (error) { - console.error("Error submitting consent:", error); - } finally { - setConsentLoading(false); - } - }, - [api, touch], - ); - - const showConsentModal = useCallback(() => { - if (modalOpen) return; - - setModalOpen(true); - - const toastId = toaster.create({ - placement: "top", - duration: null, - render: ({ dismiss }) => { - const AcceptButton = () => { - const buttonRef = useRef(null); - useConsentWherebyFocusManagement(buttonRef, wherebyRef); - return ( - - ); - }; - - return ( - - - - Can we have your permission to store this meeting's audio - recording on our servers? - - - - - - - - ); - }, - }); - - // Set modal state when toast is dismissed - toastId.then((id) => { - const checkToastStatus = setInterval(() => { - if (!toaster.isActive(id)) { - setModalOpen(false); - clearInterval(checkToastStatus); - } - }, 100); - }); - - // Handle escape key to close the toast - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - toastId.then((id) => toaster.dismiss(id)); - } - }; - - document.addEventListener("keydown", handleKeyDown); - - const cleanup = () => { - toastId.then((id) => toaster.dismiss(id)); - document.removeEventListener("keydown", handleKeyDown); - }; - - return cleanup; - }, [meetingId, handleConsent, wherebyRef, modalOpen]); - - return { showConsentModal, consentState, hasConsent, consentLoading }; -}; - -function ConsentDialogButton({ - meetingId, - wherebyRef, -}: { - meetingId: string; - wherebyRef: React.RefObject; -}) { - const { showConsentModal, consentState, hasConsent, consentLoading } = - useConsentDialog(meetingId, wherebyRef); - - if (!consentState.ready || hasConsent(meetingId) || consentLoading) { - return null; - } - - return ( - - ); -} - -const recordingTypeRequiresConsent = ( - recordingType: NonNullable, -) => { - return recordingType === "cloud"; -}; - -// next throws even with "use client" -const useWhereby = () => { - const [wherebyLoaded, setWherebyLoaded] = useState(false); - useEffect(() => { - if (typeof window !== "undefined") { - import("@whereby.com/browser-sdk/embed") - .then(() => { - setWherebyLoaded(true); - }) - .catch(console.error.bind(console)); - } - }, []); - return wherebyLoaded; -}; - -export default function Room(details: RoomDetails) { - const wherebyLoaded = useWhereby(); - const wherebyRef = useRef(null); - const roomName = details.params.roomName; - const meeting = useRoomMeeting(roomName); - const router = useRouter(); - const { isLoading, isAuthenticated } = useSessionStatus(); - - const roomUrl = meeting?.response?.host_room_url - ? meeting?.response?.host_room_url - : meeting?.response?.room_url; - - const meetingId = meeting?.response?.id; - - const recordingType = meeting?.response?.recording_type; - - const handleLeave = useCallback(() => { - router.push("/browse"); - }, [router]); - - useEffect(() => { - if ( - !isLoading && - meeting?.error && - "status" in meeting.error && - meeting.error.status === 404 - ) { - notFound(); - } - }, [isLoading, meeting?.error]); - - useEffect(() => { - if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return; - - wherebyRef.current?.addEventListener("leave", handleLeave); - - return () => { - wherebyRef.current?.removeEventListener("leave", handleLeave); - }; - }, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]); - - if (isLoading) { - return ( - - - - ); - } - - return ( - <> - {roomUrl && meetingId && wherebyLoaded && ( - <> - - {recordingType && recordingTypeRequiresConsent(recordingType) && ( - - )} - - )} - - ); -} +export default RoomContainer; diff --git a/www/app/[roomName]/room.tsx b/www/app/[roomName]/room.tsx new file mode 100644 index 00000000..e7b68b42 --- /dev/null +++ b/www/app/[roomName]/room.tsx @@ -0,0 +1,448 @@ +"use client"; + +import { roomMeetingUrl, roomUrl as getRoomUrl } from "../lib/routes"; +import { + useCallback, + useEffect, + useRef, + useState, + useContext, + RefObject, + use, +} from "react"; +import { + Box, + Button, + Text, + VStack, + HStack, + Spinner, + Icon, +} from "@chakra-ui/react"; +import { toaster } from "../components/ui/toaster"; +import { useRouter } from "next/navigation"; +import { useRecordingConsent } from "../recordingConsentContext"; +import { + useMeetingAudioConsent, + useRoomGetByName, + useRoomActiveMeetings, + useRoomUpcomingMeetings, + useRoomsCreateMeeting, + useRoomGetMeeting, +} from "../lib/apiHooks"; +import type { components } from "../reflector-api"; +import MeetingSelection from "./MeetingSelection"; +import useRoomDefaultMeeting from "./useRoomDefaultMeeting"; + +type Meeting = components["schemas"]["Meeting"]; +import { FaBars } from "react-icons/fa6"; +import { useAuth } from "../lib/AuthProvider"; +import { getWherebyUrl, useWhereby } from "../lib/wherebyClient"; +import { useError } from "../(errors)/errorContext"; +import { + assertExistsAndNonEmptyString, + NonEmptyString, + parseNonEmptyString, +} from "../lib/utils"; +import { printApiError } from "../api/_error"; + +export type RoomDetails = { + params: Promise<{ + roomName: string; + meetingId?: string; + }>; +}; + +// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially +const useConsentWherebyFocusManagement = ( + acceptButtonRef: RefObject, + wherebyRef: RefObject, +) => { + const currentFocusRef = useRef(null); + useEffect(() => { + if (acceptButtonRef.current) { + acceptButtonRef.current.focus(); + } else { + console.error( + "accept button ref not available yet for focus management - seems to be illegal state", + ); + } + + const handleWherebyReady = () => { + console.log("whereby ready - refocusing consent button"); + currentFocusRef.current = document.activeElement as HTMLElement; + if (acceptButtonRef.current) { + acceptButtonRef.current.focus(); + } + }; + + if (wherebyRef.current) { + wherebyRef.current.addEventListener("ready", handleWherebyReady); + } else { + console.warn( + "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.", + ); + } + + return () => { + wherebyRef.current?.removeEventListener("ready", handleWherebyReady); + currentFocusRef.current?.focus(); + }; + }, []); +}; + +const useConsentDialog = ( + meetingId: string, + wherebyRef: RefObject /*accessibility*/, +) => { + const { state: consentState, touch, hasConsent } = useRecordingConsent(); + // toast would open duplicates, even with using "id=" prop + const [modalOpen, setModalOpen] = useState(false); + const audioConsentMutation = useMeetingAudioConsent(); + + const handleConsent = useCallback( + async (meetingId: string, given: boolean) => { + try { + await audioConsentMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + body: { + consent_given: given, + }, + }); + + touch(meetingId); + } catch (error) { + console.error("Error submitting consent:", error); + } + }, + [audioConsentMutation, touch], + ); + + const showConsentModal = useCallback(() => { + if (modalOpen) return; + + setModalOpen(true); + + const toastId = toaster.create({ + placement: "top", + duration: null, + render: ({ dismiss }) => { + const AcceptButton = () => { + const buttonRef = useRef(null); + useConsentWherebyFocusManagement(buttonRef, wherebyRef); + return ( + + ); + }; + + return ( + + + + Can we have your permission to store this meeting's audio + recording on our servers? + + + + + + + + ); + }, + }); + + // Set modal state when toast is dismissed + toastId.then((id) => { + const checkToastStatus = setInterval(() => { + if (!toaster.isActive(id)) { + setModalOpen(false); + clearInterval(checkToastStatus); + } + }, 100); + }); + + // Handle escape key to close the toast + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + toastId.then((id) => toaster.dismiss(id)); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + const cleanup = () => { + toastId.then((id) => toaster.dismiss(id)); + document.removeEventListener("keydown", handleKeyDown); + }; + + return cleanup; + }, [meetingId, handleConsent, wherebyRef, modalOpen]); + + return { + showConsentModal, + consentState, + hasConsent, + consentLoading: audioConsentMutation.isPending, + }; +}; + +function ConsentDialogButton({ + meetingId, + wherebyRef, +}: { + meetingId: NonEmptyString; + wherebyRef: React.RefObject; +}) { + const { showConsentModal, consentState, hasConsent, consentLoading } = + useConsentDialog(meetingId, wherebyRef); + + if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + return null; + } + + return ( + + ); +} + +const recordingTypeRequiresConsent = ( + recordingType: NonNullable, +) => { + return recordingType === "cloud"; +}; + +export default function Room(details: RoomDetails) { + const params = use(details.params); + const wherebyLoaded = useWhereby(); + const wherebyRef = useRef(null); + const roomName = parseNonEmptyString( + params.roomName, + true, + "panic! params.roomName is required", + ); + const router = useRouter(); + const auth = useAuth(); + const status = auth.status; + const isAuthenticated = status === "authenticated"; + const { setError } = useError(); + + const roomQuery = useRoomGetByName(roomName); + const createMeetingMutation = useRoomsCreateMeeting(); + + const room = roomQuery.data; + + const pageMeetingId = params.meetingId; + + // this one is called on room page + const defaultMeeting = useRoomDefaultMeeting( + room && !room.ics_enabled && !pageMeetingId ? roomName : null, + ); + + const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null); + const wherebyRoomUrl = explicitMeeting.data + ? getWherebyUrl(explicitMeeting.data) + : defaultMeeting.response + ? getWherebyUrl(defaultMeeting.response) + : null; + const recordingType = (explicitMeeting.data || defaultMeeting.response) + ?.recording_type; + const meetingId = (explicitMeeting.data || defaultMeeting.response)?.id; + + const isLoading = + status === "loading" || + roomQuery.isLoading || + defaultMeeting?.loading || + explicitMeeting.isLoading; + + const errors = [ + explicitMeeting.error, + defaultMeeting.error, + roomQuery.error, + createMeetingMutation.error, + ].filter(Boolean); + + const isOwner = + isAuthenticated && room ? auth.user?.id === room.user_id : false; + + const handleMeetingSelect = (selectedMeeting: Meeting) => { + router.push( + roomMeetingUrl( + roomName, + parseNonEmptyString( + selectedMeeting.id, + true, + "panic! selectedMeeting.id is required", + ), + ), + ); + }; + + const handleCreateUnscheduled = async () => { + try { + // Create a new unscheduled meeting + const newMeeting = await createMeetingMutation.mutateAsync({ + params: { + path: { room_name: roomName }, + }, + body: { + allow_duplicated: room ? room.ics_enabled : false, + }, + }); + handleMeetingSelect(newMeeting); + } catch (err) { + console.error("Failed to create meeting:", err); + } + }; + + const handleLeave = useCallback(() => { + router.push("/browse"); + }, [router]); + + useEffect(() => { + if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded) + return; + + wherebyRef.current?.addEventListener("leave", handleLeave); + + return () => { + wherebyRef.current?.removeEventListener("leave", handleLeave); + }; + }, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]); + + useEffect(() => { + if (!isLoading && !wherebyRoomUrl) { + setError(new Error("Whereby room URL not found")); + } + }, [isLoading, wherebyRoomUrl]); + + if (isLoading) { + return ( + + + + ); + } + + if (!room) { + return ( + + Room not found + + ); + } + + if (room.ics_enabled && !params.meetingId) { + return ( + + ); + } + + if (errors.length > 0) { + return ( + + {errors.map((error, i) => ( + + {printApiError(error)} + + ))} + + ); + } + + return ( + <> + {wherebyRoomUrl && wherebyLoaded && ( + <> + + {recordingType && + recordingTypeRequiresConsent(recordingType) && + meetingId && ( + + )} + + )} + + ); +} diff --git a/www/app/[roomName]/useRoomDefaultMeeting.tsx b/www/app/[roomName]/useRoomDefaultMeeting.tsx new file mode 100644 index 00000000..724e692f --- /dev/null +++ b/www/app/[roomName]/useRoomDefaultMeeting.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState, useRef } from "react"; +import { useError } from "../(errors)/errorContext"; +import type { components } from "../reflector-api"; +import { shouldShowError } from "../lib/errorUtils"; + +type Meeting = components["schemas"]["Meeting"]; +import { useRoomsCreateMeeting } from "../lib/apiHooks"; +import { notFound } from "next/navigation"; +import { ApiError } from "../api/_error"; + +type ErrorMeeting = { + error: ApiError; + loading: false; + response: null; + reload: () => void; +}; + +type LoadingMeeting = { + error: null; + response: null; + loading: true; + reload: () => void; +}; + +type SuccessMeeting = { + error: null; + response: Meeting; + loading: false; + reload: () => void; +}; + +const useRoomDefaultMeeting = ( + roomName: string | null, +): ErrorMeeting | LoadingMeeting | SuccessMeeting => { + const [response, setResponse] = useState(null); + const [reload, setReload] = useState(0); + const { setError } = useError(); + const createMeetingMutation = useRoomsCreateMeeting(); + const reloadHandler = () => setReload((prev) => prev + 1); + + // this is to undupe dev mode room creation + const creatingRef = useRef(false); + + useEffect(() => { + if (!roomName) return; + if (creatingRef.current) return; + + const createMeeting = async () => { + creatingRef.current = true; + try { + const result = await createMeetingMutation.mutateAsync({ + params: { + path: { + room_name: roomName, + }, + }, + body: { + allow_duplicated: false, + }, + }); + setResponse(result); + } catch (error: any) { + const shouldShowHuman = shouldShowError(error); + if (shouldShowHuman && error.status !== 404) { + setError( + error, + "There was an error loading the meeting. Please try again by refreshing the page.", + ); + } else { + setError(error); + } + } finally { + creatingRef.current = false; + } + }; + + createMeeting().then(() => {}); + }, [roomName, reload]); + + const loading = createMeetingMutation.isPending && !response; + const error = createMeetingMutation.error; + + return { response, loading, error, reload: reloadHandler } as + | ErrorMeeting + | LoadingMeeting + | SuccessMeeting; +}; + +export default useRoomDefaultMeeting; diff --git a/www/app/[roomName]/useRoomMeeting.tsx b/www/app/[roomName]/useRoomMeeting.tsx deleted file mode 100644 index 98c2f1f2..00000000 --- a/www/app/[roomName]/useRoomMeeting.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useEffect, useState } from "react"; -import { useError } from "../(errors)/errorContext"; -import { Meeting } from "../api"; -import { shouldShowError } from "../lib/errorUtils"; -import useApi from "../lib/useApi"; -import { notFound } from "next/navigation"; - -type ErrorMeeting = { - error: Error; - loading: false; - response: null; - reload: () => void; -}; - -type LoadingMeeting = { - response: null; - loading: true; - error: false; - reload: () => void; -}; - -type SuccessMeeting = { - response: Meeting; - loading: false; - error: null; - reload: () => void; -}; - -const useRoomMeeting = ( - roomName: string | null | undefined, -): ErrorMeeting | LoadingMeeting | SuccessMeeting => { - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); - const [reload, setReload] = useState(0); - const { setError } = useError(); - const api = useApi(); - const reloadHandler = () => setReload((prev) => prev + 1); - - useEffect(() => { - if (!roomName || !api) return; - - if (!response) { - setLoading(true); - } - - api - .v1RoomsCreateMeeting({ roomName }) - .then((result) => { - setResponse(result); - setLoading(false); - }) - .catch((error) => { - const shouldShowHuman = shouldShowError(error); - if (shouldShowHuman && error.status !== 404) { - setError( - error, - "There was an error loading the meeting. Please try again by refreshing the page.", - ); - } else { - setError(error); - } - setErrorState(error); - }); - }, [roomName, !api, reload]); - - return { response, loading, error, reload: reloadHandler } as - | ErrorMeeting - | LoadingMeeting - | SuccessMeeting; -}; - -export default useRoomMeeting; diff --git a/www/app/api/OpenApi.ts b/www/app/api/OpenApi.ts deleted file mode 100644 index 23cc35f3..00000000 --- a/www/app/api/OpenApi.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BaseHttpRequest } from "./core/BaseHttpRequest"; -import type { OpenAPIConfig } from "./core/OpenAPI"; -import { Interceptors } from "./core/OpenAPI"; -import { AxiosHttpRequest } from "./core/AxiosHttpRequest"; - -import { DefaultService } from "./services.gen"; - -type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest; - -export class OpenApi { - public readonly default: DefaultService; - - public readonly request: BaseHttpRequest; - - constructor( - config?: Partial, - HttpRequest: HttpRequestConstructor = AxiosHttpRequest, - ) { - this.request = new HttpRequest({ - BASE: config?.BASE ?? "", - VERSION: config?.VERSION ?? "0.1.0", - WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false, - CREDENTIALS: config?.CREDENTIALS ?? "include", - TOKEN: config?.TOKEN, - USERNAME: config?.USERNAME, - PASSWORD: config?.PASSWORD, - HEADERS: config?.HEADERS, - ENCODE_PATH: config?.ENCODE_PATH, - interceptors: { - request: config?.interceptors?.request ?? new Interceptors(), - response: config?.interceptors?.response ?? new Interceptors(), - }, - }); - - this.default = new DefaultService(this.request); - } -} diff --git a/www/app/api/_error.ts b/www/app/api/_error.ts new file mode 100644 index 00000000..9603b8e8 --- /dev/null +++ b/www/app/api/_error.ts @@ -0,0 +1,26 @@ +import { components } from "../reflector-api"; +import { isArray } from "remeda"; + +export type ApiError = { + detail?: components["schemas"]["ValidationError"][]; +} | null; + +// errors as declared on api types is not != as they in reality e.g. detail may be a string +export const printApiError = (error: ApiError) => { + if (!error || !error.detail) { + return null; + } + const detail = error.detail as unknown; + if (isArray(error.detail)) { + return error.detail.map((e) => e.msg).join(", "); + } + if (typeof detail === "string") { + if (detail.length > 0) { + return detail; + } + console.error("Error detail is empty"); + return null; + } + console.error("Error detail is not a string or array"); + return null; +}; diff --git a/www/app/api/auth/[...nextauth]/route.ts b/www/app/api/auth/[...nextauth]/route.ts index 915ed04d..250e9e34 100644 --- a/www/app/api/auth/[...nextauth]/route.ts +++ b/www/app/api/auth/[...nextauth]/route.ts @@ -1,9 +1,6 @@ -// NextAuth route handler for Authentik -// Refresh rotation has been taken from https://next-auth.js.org/v3/tutorials/refresh-token-rotation even if we are using 4.x - import NextAuth from "next-auth"; -import { authOptions } from "../../../lib/auth"; +import { authOptions } from "../../../lib/authBackend"; -const handler = NextAuth(authOptions); +const handler = NextAuth(authOptions()); export { handler as GET, handler as POST }; diff --git a/www/app/api/core/ApiError.ts b/www/app/api/core/ApiError.ts deleted file mode 100644 index 1d07bb31..00000000 --- a/www/app/api/core/ApiError.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import type { ApiResult } from "./ApiResult"; - -export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: unknown; - public readonly request: ApiRequestOptions; - - constructor( - request: ApiRequestOptions, - response: ApiResult, - message: string, - ) { - super(message); - - this.name = "ApiError"; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } -} diff --git a/www/app/api/core/ApiRequestOptions.ts b/www/app/api/core/ApiRequestOptions.ts deleted file mode 100644 index 57fbb095..00000000 --- a/www/app/api/core/ApiRequestOptions.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type ApiRequestOptions = { - readonly method: - | "GET" - | "PUT" - | "POST" - | "DELETE" - | "OPTIONS" - | "HEAD" - | "PATCH"; - readonly url: string; - readonly path?: Record; - readonly cookies?: Record; - readonly headers?: Record; - readonly query?: Record; - readonly formData?: Record; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly responseTransformer?: (data: unknown) => Promise; - readonly errors?: Record; -}; diff --git a/www/app/api/core/ApiResult.ts b/www/app/api/core/ApiResult.ts deleted file mode 100644 index 05040ba8..00000000 --- a/www/app/api/core/ApiResult.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type ApiResult = { - readonly body: TData; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly url: string; -}; diff --git a/www/app/api/core/AxiosHttpRequest.ts b/www/app/api/core/AxiosHttpRequest.ts deleted file mode 100644 index aba5096e..00000000 --- a/www/app/api/core/AxiosHttpRequest.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import { BaseHttpRequest } from "./BaseHttpRequest"; -import type { CancelablePromise } from "./CancelablePromise"; -import type { OpenAPIConfig } from "./OpenAPI"; -import { request as __request } from "./request"; - -export class AxiosHttpRequest extends BaseHttpRequest { - constructor(config: OpenAPIConfig) { - super(config); - } - - /** - * Request method - * @param options The request options from the service - * @returns CancelablePromise - * @throws ApiError - */ - public override request( - options: ApiRequestOptions, - ): CancelablePromise { - return __request(this.config, options); - } -} diff --git a/www/app/api/core/BaseHttpRequest.ts b/www/app/api/core/BaseHttpRequest.ts deleted file mode 100644 index 3f89861c..00000000 --- a/www/app/api/core/BaseHttpRequest.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import type { CancelablePromise } from "./CancelablePromise"; -import type { OpenAPIConfig } from "./OpenAPI"; - -export abstract class BaseHttpRequest { - constructor(public readonly config: OpenAPIConfig) {} - - public abstract request( - options: ApiRequestOptions, - ): CancelablePromise; -} diff --git a/www/app/api/core/CancelablePromise.ts b/www/app/api/core/CancelablePromise.ts deleted file mode 100644 index 0640e989..00000000 --- a/www/app/api/core/CancelablePromise.ts +++ /dev/null @@ -1,126 +0,0 @@ -export class CancelError extends Error { - constructor(message: string) { - super(message); - this.name = "CancelError"; - } - - public get isCancelled(): boolean { - return true; - } -} - -export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; - - (cancelHandler: () => void): void; -} - -export class CancelablePromise implements Promise { - private _isResolved: boolean; - private _isRejected: boolean; - private _isCancelled: boolean; - readonly cancelHandlers: (() => void)[]; - readonly promise: Promise; - private _resolve?: (value: T | PromiseLike) => void; - private _reject?: (reason?: unknown) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike) => void, - reject: (reason?: unknown) => void, - onCancel: OnCancel, - ) => void, - ) { - this._isResolved = false; - this._isRejected = false; - this._isCancelled = false; - this.cancelHandlers = []; - this.promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; - - const onResolve = (value: T | PromiseLike): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return; - } - this._isResolved = true; - if (this._resolve) this._resolve(value); - }; - - const onReject = (reason?: unknown): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return; - } - this._isRejected = true; - if (this._reject) this._reject(reason); - }; - - const onCancel = (cancelHandler: () => void): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return; - } - this.cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, "isResolved", { - get: (): boolean => this._isResolved, - }); - - Object.defineProperty(onCancel, "isRejected", { - get: (): boolean => this._isRejected, - }); - - Object.defineProperty(onCancel, "isCancelled", { - get: (): boolean => this._isCancelled, - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return "Cancellable Promise"; - } - - public then( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return this.promise.then(onFulfilled, onRejected); - } - - public catch( - onRejected?: ((reason: unknown) => TResult | PromiseLike) | null, - ): Promise { - return this.promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise { - return this.promise.finally(onFinally); - } - - public cancel(): void { - if (this._isResolved || this._isRejected || this._isCancelled) { - return; - } - this._isCancelled = true; - if (this.cancelHandlers.length) { - try { - for (const cancelHandler of this.cancelHandlers) { - cancelHandler(); - } - } catch (error) { - console.warn("Cancellation threw an error", error); - return; - } - } - this.cancelHandlers.length = 0; - if (this._reject) this._reject(new CancelError("Request aborted")); - } - - public get isCancelled(): boolean { - return this._isCancelled; - } -} diff --git a/www/app/api/core/OpenAPI.ts b/www/app/api/core/OpenAPI.ts deleted file mode 100644 index 20ea0ed9..00000000 --- a/www/app/api/core/OpenAPI.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { AxiosRequestConfig, AxiosResponse } from "axios"; -import type { ApiRequestOptions } from "./ApiRequestOptions"; - -type Headers = Record; -type Middleware = (value: T) => T | Promise; -type Resolver = (options: ApiRequestOptions) => Promise; - -export class Interceptors { - _fns: Middleware[]; - - constructor() { - this._fns = []; - } - - eject(fn: Middleware): void { - const index = this._fns.indexOf(fn); - if (index !== -1) { - this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; - } - } - - use(fn: Middleware): void { - this._fns = [...this._fns, fn]; - } -} - -export type OpenAPIConfig = { - BASE: string; - CREDENTIALS: "include" | "omit" | "same-origin"; - ENCODE_PATH?: ((path: string) => string) | undefined; - HEADERS?: Headers | Resolver | undefined; - PASSWORD?: string | Resolver | undefined; - TOKEN?: string | Resolver | undefined; - USERNAME?: string | Resolver | undefined; - VERSION: string; - WITH_CREDENTIALS: boolean; - interceptors: { - request: Interceptors; - response: Interceptors; - }; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: "", - CREDENTIALS: "include", - ENCODE_PATH: undefined, - HEADERS: undefined, - PASSWORD: undefined, - TOKEN: undefined, - USERNAME: undefined, - VERSION: "0.1.0", - WITH_CREDENTIALS: false, - interceptors: { - request: new Interceptors(), - response: new Interceptors(), - }, -}; diff --git a/www/app/api/core/request.ts b/www/app/api/core/request.ts deleted file mode 100644 index b576207e..00000000 --- a/www/app/api/core/request.ts +++ /dev/null @@ -1,387 +0,0 @@ -import axios from "axios"; -import type { - AxiosError, - AxiosRequestConfig, - AxiosResponse, - AxiosInstance, -} from "axios"; - -import { ApiError } from "./ApiError"; -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import type { ApiResult } from "./ApiResult"; -import { CancelablePromise } from "./CancelablePromise"; -import type { OnCancel } from "./CancelablePromise"; -import type { OpenAPIConfig } from "./OpenAPI"; - -export const isString = (value: unknown): value is string => { - return typeof value === "string"; -}; - -export const isStringWithValue = (value: unknown): value is string => { - return isString(value) && value !== ""; -}; - -export const isBlob = (value: any): value is Blob => { - return value instanceof Blob; -}; - -export const isFormData = (value: unknown): value is FormData => { - return value instanceof FormData; -}; - -export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; -}; - -export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString("base64"); - } -}; - -export const getQueryString = (params: Record): string => { - const qs: string[] = []; - - const append = (key: string, value: unknown) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const encodePair = (key: string, value: unknown) => { - if (value === undefined || value === null) { - return; - } - - if (value instanceof Date) { - append(key, value.toISOString()); - } else if (Array.isArray(value)) { - value.forEach((v) => encodePair(key, v)); - } else if (typeof value === "object") { - Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v)); - } else { - append(key, value); - } - }; - - Object.entries(params).forEach(([key, value]) => encodePair(key, value)); - - return qs.length ? `?${qs.join("&")}` : ""; -}; - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace("{api-version}", config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = config.BASE + path; - return options.query ? url + getQueryString(options.query) : url; -}; - -export const getFormData = ( - options: ApiRequestOptions, -): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: unknown) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([, value]) => value !== undefined && value !== null) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((v) => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; -}; - -type Resolver = (options: ApiRequestOptions) => Promise; - -export const resolve = async ( - options: ApiRequestOptions, - resolver?: T | Resolver, -): Promise => { - if (typeof resolver === "function") { - return (resolver as Resolver)(options); - } - return resolver; -}; - -export const getHeaders = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, -): Promise> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - // @ts-ignore - resolve(options, config.TOKEN), - // @ts-ignore - resolve(options, config.USERNAME), - // @ts-ignore - resolve(options, config.PASSWORD), - // @ts-ignore - resolve(options, config.HEADERS), - ]); - - const headers = Object.entries({ - Accept: "application/json", - ...additionalHeaders, - ...options.headers, - }) - .filter(([, value]) => value !== undefined && value !== null) - .reduce( - (headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), - {} as Record, - ); - - if (isStringWithValue(token)) { - headers["Authorization"] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers["Authorization"] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers["Content-Type"] = options.mediaType; - } else if (isBlob(options.body)) { - headers["Content-Type"] = options.body.type || "application/octet-stream"; - } else if (isString(options.body)) { - headers["Content-Type"] = "text/plain"; - } else if (!isFormData(options.body)) { - headers["Content-Type"] = "application/json"; - } - } else if (options.formData !== undefined) { - if (options.mediaType) { - headers["Content-Type"] = options.mediaType; - } - } - - return headers; -}; - -export const getRequestBody = (options: ApiRequestOptions): unknown => { - if (options.body) { - return options.body; - } - return undefined; -}; - -export const sendRequest = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: unknown, - formData: FormData | undefined, - headers: Record, - onCancel: OnCancel, - axiosClient: AxiosInstance, -): Promise> => { - const controller = new AbortController(); - - let requestConfig: AxiosRequestConfig = { - data: body ?? formData, - headers, - method: options.method, - signal: controller.signal, - url, - withCredentials: config.WITH_CREDENTIALS, - }; - - onCancel(() => controller.abort()); - - for (const fn of config.interceptors.request._fns) { - requestConfig = await fn(requestConfig); - } - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError; - if (axiosError.response) { - return axiosError.response; - } - throw error; - } -}; - -export const getResponseHeader = ( - response: AxiosResponse, - responseHeader?: string, -): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = (response: AxiosResponse): unknown => { - if (response.status !== 204) { - return response.data; - } - return undefined; -}; - -export const catchErrorCodes = ( - options: ApiRequestOptions, - result: ApiResult, -): void => { - const errors: Record = { - 400: "Bad Request", - 401: "Unauthorized", - 402: "Payment Required", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 406: "Not Acceptable", - 407: "Proxy Authentication Required", - 408: "Request Timeout", - 409: "Conflict", - 410: "Gone", - 411: "Length Required", - 412: "Precondition Failed", - 413: "Payload Too Large", - 414: "URI Too Long", - 415: "Unsupported Media Type", - 416: "Range Not Satisfiable", - 417: "Expectation Failed", - 418: "Im a teapot", - 421: "Misdirected Request", - 422: "Unprocessable Content", - 423: "Locked", - 424: "Failed Dependency", - 425: "Too Early", - 426: "Upgrade Required", - 428: "Precondition Required", - 429: "Too Many Requests", - 431: "Request Header Fields Too Large", - 451: "Unavailable For Legal Reasons", - 500: "Internal Server Error", - 501: "Not Implemented", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - 505: "HTTP Version Not Supported", - 506: "Variant Also Negotiates", - 507: "Insufficient Storage", - 508: "Loop Detected", - 510: "Not Extended", - 511: "Network Authentication Required", - ...options.errors, - }; - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? "unknown"; - const errorStatusText = result.statusText ?? "unknown"; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError( - options, - result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`, - ); - } -}; - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @param axiosClient The axios client instance to use - * @returns CancelablePromise - * @throws ApiError - */ -export const request = ( - config: OpenAPIConfig, - options: ApiRequestOptions, - axiosClient: AxiosInstance = axios, -): CancelablePromise => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options); - - if (!onCancel.isCancelled) { - let response = await sendRequest( - config, - options, - url, - body, - formData, - headers, - onCancel, - axiosClient, - ); - - for (const fn of config.interceptors.response._fns) { - response = await fn(response); - } - - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader( - response, - options.responseHeader, - ); - - let transformedBody = responseBody; - if (options.responseTransformer && isSuccess(response.status)) { - transformedBody = await options.responseTransformer(responseBody); - } - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? transformedBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; diff --git a/www/app/api/health/route.ts b/www/app/api/health/route.ts new file mode 100644 index 00000000..80a58b7c --- /dev/null +++ b/www/app/api/health/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + const health = { + status: "healthy", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: process.env.NODE_ENV, + checks: { + redis: await checkRedis(), + }, + }; + + const allHealthy = Object.values(health.checks).every((check) => check); + + return NextResponse.json(health, { + status: allHealthy ? 200 : 503, + }); +} + +async function checkRedis(): Promise { + try { + if (!process.env.KV_URL) { + return false; + } + + const { tokenCacheRedis } = await import("../../lib/redisClient"); + const testKey = `health:check:${Date.now()}`; + await tokenCacheRedis.setex(testKey, 10, "OK"); + const value = await tokenCacheRedis.get(testKey); + await tokenCacheRedis.del(testKey); + + return value === "OK"; + } catch (error) { + console.error("Redis health check failed:", error); + return false; + } +} diff --git a/www/app/api/index.ts b/www/app/api/index.ts deleted file mode 100644 index 27fbb57d..00000000 --- a/www/app/api/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts -export { OpenApi } from "./OpenApi"; -export { ApiError } from "./core/ApiError"; -export { BaseHttpRequest } from "./core/BaseHttpRequest"; -export { CancelablePromise, CancelError } from "./core/CancelablePromise"; -export { OpenAPI, type OpenAPIConfig } from "./core/OpenAPI"; -export * from "./schemas.gen"; -export * from "./services.gen"; -export * from "./types.gen"; diff --git a/www/app/api/schemas.gen.ts b/www/app/api/schemas.gen.ts deleted file mode 100644 index 7439241a..00000000 --- a/www/app/api/schemas.gen.ts +++ /dev/null @@ -1,1601 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export const $AudioWaveform = { - properties: { - data: { - items: { - type: "number", - }, - type: "array", - title: "Data", - }, - }, - type: "object", - required: ["data"], - title: "AudioWaveform", -} as const; - -export const $Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post = - { - properties: { - chunk: { - type: "string", - format: "binary", - title: "Chunk", - }, - }, - type: "object", - required: ["chunk"], - title: - "Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post", - } as const; - -export const $CreateParticipant = { - properties: { - speaker: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["name"], - title: "CreateParticipant", -} as const; - -export const $CreateRoom = { - properties: { - name: { - type: "string", - title: "Name", - }, - zulip_auto_post: { - type: "boolean", - title: "Zulip Auto Post", - }, - zulip_stream: { - type: "string", - title: "Zulip Stream", - }, - zulip_topic: { - type: "string", - title: "Zulip Topic", - }, - is_locked: { - type: "boolean", - title: "Is Locked", - }, - room_mode: { - type: "string", - title: "Room Mode", - }, - recording_type: { - type: "string", - title: "Recording Type", - }, - recording_trigger: { - type: "string", - title: "Recording Trigger", - }, - is_shared: { - type: "boolean", - title: "Is Shared", - }, - }, - type: "object", - required: [ - "name", - "zulip_auto_post", - "zulip_stream", - "zulip_topic", - "is_locked", - "room_mode", - "recording_type", - "recording_trigger", - "is_shared", - ], - title: "CreateRoom", -} as const; - -export const $CreateTranscript = { - properties: { - name: { - type: "string", - title: "Name", - }, - source_language: { - type: "string", - title: "Source Language", - default: "en", - }, - target_language: { - type: "string", - title: "Target Language", - default: "en", - }, - }, - type: "object", - required: ["name"], - title: "CreateTranscript", -} as const; - -export const $DeletionStatus = { - properties: { - status: { - type: "string", - title: "Status", - }, - }, - type: "object", - required: ["status"], - title: "DeletionStatus", -} as const; - -export const $GetTranscript = { - properties: { - id: { - type: "string", - title: "Id", - }, - user_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "User Id", - }, - name: { - type: "string", - title: "Name", - }, - status: { - type: "string", - title: "Status", - }, - locked: { - type: "boolean", - title: "Locked", - }, - duration: { - type: "number", - title: "Duration", - }, - title: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Title", - }, - short_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Short Summary", - }, - long_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Long Summary", - }, - created_at: { - type: "string", - title: "Created At", - }, - share_mode: { - type: "string", - title: "Share Mode", - default: "private", - }, - source_language: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Source Language", - }, - target_language: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Target Language", - }, - reviewed: { - type: "boolean", - title: "Reviewed", - }, - meeting_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Meeting Id", - }, - source_kind: { - $ref: "#/components/schemas/SourceKind", - }, - room_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Id", - }, - room_name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Name", - }, - audio_deleted: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Audio Deleted", - }, - participants: { - anyOf: [ - { - items: { - $ref: "#/components/schemas/TranscriptParticipant", - }, - type: "array", - }, - { - type: "null", - }, - ], - title: "Participants", - }, - }, - type: "object", - required: [ - "id", - "user_id", - "name", - "status", - "locked", - "duration", - "title", - "short_summary", - "long_summary", - "created_at", - "source_language", - "target_language", - "reviewed", - "meeting_id", - "source_kind", - "participants", - ], - title: "GetTranscript", -} as const; - -export const $GetTranscriptMinimal = { - properties: { - id: { - type: "string", - title: "Id", - }, - user_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "User Id", - }, - name: { - type: "string", - title: "Name", - }, - status: { - type: "string", - title: "Status", - }, - locked: { - type: "boolean", - title: "Locked", - }, - duration: { - type: "number", - title: "Duration", - }, - title: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Title", - }, - short_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Short Summary", - }, - long_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Long Summary", - }, - created_at: { - type: "string", - title: "Created At", - }, - share_mode: { - type: "string", - title: "Share Mode", - default: "private", - }, - source_language: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Source Language", - }, - target_language: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Target Language", - }, - reviewed: { - type: "boolean", - title: "Reviewed", - }, - meeting_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Meeting Id", - }, - source_kind: { - $ref: "#/components/schemas/SourceKind", - }, - room_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Id", - }, - room_name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Name", - }, - audio_deleted: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Audio Deleted", - }, - }, - type: "object", - required: [ - "id", - "user_id", - "name", - "status", - "locked", - "duration", - "title", - "short_summary", - "long_summary", - "created_at", - "source_language", - "target_language", - "reviewed", - "meeting_id", - "source_kind", - ], - title: "GetTranscriptMinimal", -} as const; - -export const $GetTranscriptSegmentTopic = { - properties: { - text: { - type: "string", - title: "Text", - }, - start: { - type: "number", - title: "Start", - }, - speaker: { - type: "integer", - title: "Speaker", - }, - }, - type: "object", - required: ["text", "start", "speaker"], - title: "GetTranscriptSegmentTopic", -} as const; - -export const $GetTranscriptTopic = { - properties: { - id: { - type: "string", - title: "Id", - }, - title: { - type: "string", - title: "Title", - }, - summary: { - type: "string", - title: "Summary", - }, - timestamp: { - type: "number", - title: "Timestamp", - }, - duration: { - anyOf: [ - { - type: "number", - }, - { - type: "null", - }, - ], - title: "Duration", - }, - transcript: { - type: "string", - title: "Transcript", - }, - segments: { - items: { - $ref: "#/components/schemas/GetTranscriptSegmentTopic", - }, - type: "array", - title: "Segments", - default: [], - }, - }, - type: "object", - required: ["id", "title", "summary", "timestamp", "duration", "transcript"], - title: "GetTranscriptTopic", -} as const; - -export const $GetTranscriptTopicWithWords = { - properties: { - id: { - type: "string", - title: "Id", - }, - title: { - type: "string", - title: "Title", - }, - summary: { - type: "string", - title: "Summary", - }, - timestamp: { - type: "number", - title: "Timestamp", - }, - duration: { - anyOf: [ - { - type: "number", - }, - { - type: "null", - }, - ], - title: "Duration", - }, - transcript: { - type: "string", - title: "Transcript", - }, - segments: { - items: { - $ref: "#/components/schemas/GetTranscriptSegmentTopic", - }, - type: "array", - title: "Segments", - default: [], - }, - words: { - items: { - $ref: "#/components/schemas/Word", - }, - type: "array", - title: "Words", - default: [], - }, - }, - type: "object", - required: ["id", "title", "summary", "timestamp", "duration", "transcript"], - title: "GetTranscriptTopicWithWords", -} as const; - -export const $GetTranscriptTopicWithWordsPerSpeaker = { - properties: { - id: { - type: "string", - title: "Id", - }, - title: { - type: "string", - title: "Title", - }, - summary: { - type: "string", - title: "Summary", - }, - timestamp: { - type: "number", - title: "Timestamp", - }, - duration: { - anyOf: [ - { - type: "number", - }, - { - type: "null", - }, - ], - title: "Duration", - }, - transcript: { - type: "string", - title: "Transcript", - }, - segments: { - items: { - $ref: "#/components/schemas/GetTranscriptSegmentTopic", - }, - type: "array", - title: "Segments", - default: [], - }, - words_per_speaker: { - items: { - $ref: "#/components/schemas/SpeakerWords", - }, - type: "array", - title: "Words Per Speaker", - default: [], - }, - }, - type: "object", - required: ["id", "title", "summary", "timestamp", "duration", "transcript"], - title: "GetTranscriptTopicWithWordsPerSpeaker", -} as const; - -export const $HTTPValidationError = { - properties: { - detail: { - items: { - $ref: "#/components/schemas/ValidationError", - }, - type: "array", - title: "Detail", - }, - }, - type: "object", - title: "HTTPValidationError", -} as const; - -export const $Meeting = { - properties: { - id: { - type: "string", - title: "Id", - }, - room_name: { - type: "string", - title: "Room Name", - }, - room_url: { - type: "string", - title: "Room Url", - }, - host_room_url: { - type: "string", - title: "Host Room Url", - }, - start_date: { - type: "string", - format: "date-time", - title: "Start Date", - }, - end_date: { - type: "string", - format: "date-time", - title: "End Date", - }, - recording_type: { - type: "string", - enum: ["none", "local", "cloud"], - title: "Recording Type", - default: "cloud", - }, - }, - type: "object", - required: [ - "id", - "room_name", - "room_url", - "host_room_url", - "start_date", - "end_date", - ], - title: "Meeting", -} as const; - -export const $MeetingConsentRequest = { - properties: { - consent_given: { - type: "boolean", - title: "Consent Given", - }, - }, - type: "object", - required: ["consent_given"], - title: "MeetingConsentRequest", -} as const; - -export const $Page_GetTranscriptMinimal_ = { - properties: { - items: { - items: { - $ref: "#/components/schemas/GetTranscriptMinimal", - }, - type: "array", - title: "Items", - }, - total: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Total", - }, - page: { - anyOf: [ - { - type: "integer", - minimum: 1, - }, - { - type: "null", - }, - ], - title: "Page", - }, - size: { - anyOf: [ - { - type: "integer", - minimum: 1, - }, - { - type: "null", - }, - ], - title: "Size", - }, - pages: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Pages", - }, - }, - type: "object", - required: ["items", "page", "size"], - title: "Page[GetTranscriptMinimal]", -} as const; - -export const $Page_Room_ = { - properties: { - items: { - items: { - $ref: "#/components/schemas/Room", - }, - type: "array", - title: "Items", - }, - total: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Total", - }, - page: { - anyOf: [ - { - type: "integer", - minimum: 1, - }, - { - type: "null", - }, - ], - title: "Page", - }, - size: { - anyOf: [ - { - type: "integer", - minimum: 1, - }, - { - type: "null", - }, - ], - title: "Size", - }, - pages: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Pages", - }, - }, - type: "object", - required: ["items", "page", "size"], - title: "Page[Room]", -} as const; - -export const $Participant = { - properties: { - id: { - type: "string", - title: "Id", - }, - speaker: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["id", "speaker", "name"], - title: "Participant", -} as const; - -export const $Room = { - properties: { - id: { - type: "string", - title: "Id", - }, - name: { - type: "string", - title: "Name", - }, - user_id: { - type: "string", - title: "User Id", - }, - created_at: { - type: "string", - format: "date-time", - title: "Created At", - }, - zulip_auto_post: { - type: "boolean", - title: "Zulip Auto Post", - }, - zulip_stream: { - type: "string", - title: "Zulip Stream", - }, - zulip_topic: { - type: "string", - title: "Zulip Topic", - }, - is_locked: { - type: "boolean", - title: "Is Locked", - }, - room_mode: { - type: "string", - title: "Room Mode", - }, - recording_type: { - type: "string", - title: "Recording Type", - }, - recording_trigger: { - type: "string", - title: "Recording Trigger", - }, - is_shared: { - type: "boolean", - title: "Is Shared", - }, - }, - type: "object", - required: [ - "id", - "name", - "user_id", - "created_at", - "zulip_auto_post", - "zulip_stream", - "zulip_topic", - "is_locked", - "room_mode", - "recording_type", - "recording_trigger", - "is_shared", - ], - title: "Room", -} as const; - -export const $RtcOffer = { - properties: { - sdp: { - type: "string", - title: "Sdp", - }, - type: { - type: "string", - title: "Type", - }, - }, - type: "object", - required: ["sdp", "type"], - title: "RtcOffer", -} as const; - -export const $SearchResponse = { - properties: { - results: { - items: { - $ref: "#/components/schemas/SearchResult", - }, - type: "array", - title: "Results", - }, - total: { - type: "integer", - minimum: 0, - title: "Total", - description: "Total number of search results", - }, - query: { - type: "string", - minLength: 0, - title: "Query", - description: "Search query text", - }, - limit: { - type: "integer", - maximum: 100, - minimum: 1, - title: "Limit", - description: "Results per page", - }, - offset: { - type: "integer", - minimum: 0, - title: "Offset", - description: "Number of results to skip", - }, - }, - type: "object", - required: ["results", "total", "query", "limit", "offset"], - title: "SearchResponse", -} as const; - -export const $SearchResult = { - properties: { - id: { - type: "string", - minLength: 1, - title: "Id", - }, - title: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Title", - }, - user_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "User Id", - }, - room_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Id", - }, - room_name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Name", - }, - source_kind: { - $ref: "#/components/schemas/SourceKind", - }, - created_at: { - type: "string", - title: "Created At", - }, - status: { - type: "string", - minLength: 1, - title: "Status", - }, - rank: { - type: "number", - maximum: 1, - minimum: 0, - title: "Rank", - }, - duration: { - anyOf: [ - { - type: "number", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Duration", - description: "Duration in seconds", - }, - search_snippets: { - items: { - type: "string", - }, - type: "array", - title: "Search Snippets", - description: "Text snippets around search matches", - }, - total_match_count: { - type: "integer", - minimum: 0, - title: "Total Match Count", - description: "Total number of matches found in the transcript", - default: 0, - }, - }, - type: "object", - required: [ - "id", - "source_kind", - "created_at", - "status", - "rank", - "duration", - "search_snippets", - ], - title: "SearchResult", - description: "Public search result model with computed fields.", -} as const; - -export const $SourceKind = { - type: "string", - enum: ["room", "live", "file"], - title: "SourceKind", -} as const; - -export const $SpeakerAssignment = { - properties: { - speaker: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - participant: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Participant", - }, - timestamp_from: { - type: "number", - title: "Timestamp From", - }, - timestamp_to: { - type: "number", - title: "Timestamp To", - }, - }, - type: "object", - required: ["timestamp_from", "timestamp_to"], - title: "SpeakerAssignment", -} as const; - -export const $SpeakerAssignmentStatus = { - properties: { - status: { - type: "string", - title: "Status", - }, - }, - type: "object", - required: ["status"], - title: "SpeakerAssignmentStatus", -} as const; - -export const $SpeakerMerge = { - properties: { - speaker_from: { - type: "integer", - title: "Speaker From", - }, - speaker_to: { - type: "integer", - title: "Speaker To", - }, - }, - type: "object", - required: ["speaker_from", "speaker_to"], - title: "SpeakerMerge", -} as const; - -export const $SpeakerWords = { - properties: { - speaker: { - type: "integer", - title: "Speaker", - }, - words: { - items: { - $ref: "#/components/schemas/Word", - }, - type: "array", - title: "Words", - }, - }, - type: "object", - required: ["speaker", "words"], - title: "SpeakerWords", -} as const; - -export const $Stream = { - properties: { - stream_id: { - type: "integer", - title: "Stream Id", - }, - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["stream_id", "name"], - title: "Stream", -} as const; - -export const $Topic = { - properties: { - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["name"], - title: "Topic", -} as const; - -export const $TranscriptParticipant = { - properties: { - id: { - type: "string", - title: "Id", - }, - speaker: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["speaker", "name"], - title: "TranscriptParticipant", -} as const; - -export const $UpdateParticipant = { - properties: { - speaker: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Name", - }, - }, - type: "object", - title: "UpdateParticipant", -} as const; - -export const $UpdateRoom = { - properties: { - name: { - type: "string", - title: "Name", - }, - zulip_auto_post: { - type: "boolean", - title: "Zulip Auto Post", - }, - zulip_stream: { - type: "string", - title: "Zulip Stream", - }, - zulip_topic: { - type: "string", - title: "Zulip Topic", - }, - is_locked: { - type: "boolean", - title: "Is Locked", - }, - room_mode: { - type: "string", - title: "Room Mode", - }, - recording_type: { - type: "string", - title: "Recording Type", - }, - recording_trigger: { - type: "string", - title: "Recording Trigger", - }, - is_shared: { - type: "boolean", - title: "Is Shared", - }, - }, - type: "object", - required: [ - "name", - "zulip_auto_post", - "zulip_stream", - "zulip_topic", - "is_locked", - "room_mode", - "recording_type", - "recording_trigger", - "is_shared", - ], - title: "UpdateRoom", -} as const; - -export const $UpdateTranscript = { - properties: { - name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Name", - }, - locked: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Locked", - }, - title: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Title", - }, - short_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Short Summary", - }, - long_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Long Summary", - }, - share_mode: { - anyOf: [ - { - type: "string", - enum: ["public", "semi-private", "private"], - }, - { - type: "null", - }, - ], - title: "Share Mode", - }, - participants: { - anyOf: [ - { - items: { - $ref: "#/components/schemas/TranscriptParticipant", - }, - type: "array", - }, - { - type: "null", - }, - ], - title: "Participants", - }, - reviewed: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Reviewed", - }, - audio_deleted: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Audio Deleted", - }, - }, - type: "object", - title: "UpdateTranscript", -} as const; - -export const $UserInfo = { - properties: { - sub: { - type: "string", - title: "Sub", - }, - email: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Email", - }, - email_verified: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Email Verified", - }, - }, - type: "object", - required: ["sub", "email", "email_verified"], - title: "UserInfo", -} as const; - -export const $ValidationError = { - properties: { - loc: { - items: { - anyOf: [ - { - type: "string", - }, - { - type: "integer", - }, - ], - }, - type: "array", - title: "Location", - }, - msg: { - type: "string", - title: "Message", - }, - type: { - type: "string", - title: "Error Type", - }, - }, - type: "object", - required: ["loc", "msg", "type"], - title: "ValidationError", -} as const; - -export const $WherebyWebhookEvent = { - properties: { - apiVersion: { - type: "string", - title: "Apiversion", - }, - id: { - type: "string", - title: "Id", - }, - createdAt: { - type: "string", - format: "date-time", - title: "Createdat", - }, - type: { - type: "string", - title: "Type", - }, - data: { - additionalProperties: true, - type: "object", - title: "Data", - }, - }, - type: "object", - required: ["apiVersion", "id", "createdAt", "type", "data"], - title: "WherebyWebhookEvent", -} as const; - -export const $Word = { - properties: { - text: { - type: "string", - title: "Text", - }, - start: { - type: "number", - minimum: 0, - title: "Start", - description: "Time in seconds with float part", - }, - end: { - type: "number", - minimum: 0, - title: "End", - description: "Time in seconds with float part", - }, - speaker: { - type: "integer", - title: "Speaker", - default: 0, - }, - }, - type: "object", - required: ["text", "start", "end"], - title: "Word", -} as const; diff --git a/www/app/api/services.gen.ts b/www/app/api/services.gen.ts deleted file mode 100644 index 31ba098c..00000000 --- a/www/app/api/services.gen.ts +++ /dev/null @@ -1,893 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { CancelablePromise } from "./core/CancelablePromise"; -import type { BaseHttpRequest } from "./core/BaseHttpRequest"; -import type { - MetricsResponse, - V1MeetingAudioConsentData, - V1MeetingAudioConsentResponse, - V1RoomsListData, - V1RoomsListResponse, - V1RoomsCreateData, - V1RoomsCreateResponse, - V1RoomsUpdateData, - V1RoomsUpdateResponse, - V1RoomsDeleteData, - V1RoomsDeleteResponse, - V1RoomsCreateMeetingData, - V1RoomsCreateMeetingResponse, - V1TranscriptsListData, - V1TranscriptsListResponse, - V1TranscriptsCreateData, - V1TranscriptsCreateResponse, - V1TranscriptsSearchData, - V1TranscriptsSearchResponse, - V1TranscriptGetData, - V1TranscriptGetResponse, - V1TranscriptUpdateData, - V1TranscriptUpdateResponse, - V1TranscriptDeleteData, - V1TranscriptDeleteResponse, - V1TranscriptGetTopicsData, - V1TranscriptGetTopicsResponse, - V1TranscriptGetTopicsWithWordsData, - V1TranscriptGetTopicsWithWordsResponse, - V1TranscriptGetTopicsWithWordsPerSpeakerData, - V1TranscriptGetTopicsWithWordsPerSpeakerResponse, - V1TranscriptPostToZulipData, - V1TranscriptPostToZulipResponse, - V1TranscriptHeadAudioMp3Data, - V1TranscriptHeadAudioMp3Response, - V1TranscriptGetAudioMp3Data, - V1TranscriptGetAudioMp3Response, - V1TranscriptGetAudioWaveformData, - V1TranscriptGetAudioWaveformResponse, - V1TranscriptGetParticipantsData, - V1TranscriptGetParticipantsResponse, - V1TranscriptAddParticipantData, - V1TranscriptAddParticipantResponse, - V1TranscriptGetParticipantData, - V1TranscriptGetParticipantResponse, - V1TranscriptUpdateParticipantData, - V1TranscriptUpdateParticipantResponse, - V1TranscriptDeleteParticipantData, - V1TranscriptDeleteParticipantResponse, - V1TranscriptAssignSpeakerData, - V1TranscriptAssignSpeakerResponse, - V1TranscriptMergeSpeakerData, - V1TranscriptMergeSpeakerResponse, - V1TranscriptRecordUploadData, - V1TranscriptRecordUploadResponse, - V1TranscriptGetWebsocketEventsData, - V1TranscriptGetWebsocketEventsResponse, - V1TranscriptRecordWebrtcData, - V1TranscriptRecordWebrtcResponse, - V1TranscriptProcessData, - V1TranscriptProcessResponse, - V1UserMeResponse, - V1ZulipGetStreamsResponse, - V1ZulipGetTopicsData, - V1ZulipGetTopicsResponse, - V1WherebyWebhookData, - V1WherebyWebhookResponse, -} from "./types.gen"; - -export class DefaultService { - constructor(public readonly httpRequest: BaseHttpRequest) {} - - /** - * Metrics - * Endpoint that serves Prometheus metrics. - * @returns unknown Successful Response - * @throws ApiError - */ - public metrics(): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/metrics", - }); - } - - /** - * Meeting Audio Consent - * @param data The data for the request. - * @param data.meetingId - * @param data.requestBody - * @returns unknown Successful Response - * @throws ApiError - */ - public v1MeetingAudioConsent( - data: V1MeetingAudioConsentData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/meetings/{meeting_id}/consent", - path: { - meeting_id: data.meetingId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms List - * @param data The data for the request. - * @param data.page Page number - * @param data.size Page size - * @returns Page_Room_ Successful Response - * @throws ApiError - */ - public v1RoomsList( - data: V1RoomsListData = {}, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/rooms", - query: { - page: data.page, - size: data.size, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Create - * @param data The data for the request. - * @param data.requestBody - * @returns Room Successful Response - * @throws ApiError - */ - public v1RoomsCreate( - data: V1RoomsCreateData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/rooms", - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Update - * @param data The data for the request. - * @param data.roomId - * @param data.requestBody - * @returns Room Successful Response - * @throws ApiError - */ - public v1RoomsUpdate( - data: V1RoomsUpdateData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/rooms/{room_id}", - path: { - room_id: data.roomId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Delete - * @param data The data for the request. - * @param data.roomId - * @returns DeletionStatus Successful Response - * @throws ApiError - */ - public v1RoomsDelete( - data: V1RoomsDeleteData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "DELETE", - url: "/v1/rooms/{room_id}", - path: { - room_id: data.roomId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Create Meeting - * @param data The data for the request. - * @param data.roomName - * @returns Meeting Successful Response - * @throws ApiError - */ - public v1RoomsCreateMeeting( - data: V1RoomsCreateMeetingData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/rooms/{room_name}/meeting", - path: { - room_name: data.roomName, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcripts List - * @param data The data for the request. - * @param data.sourceKind - * @param data.roomId - * @param data.searchTerm - * @param data.page Page number - * @param data.size Page size - * @returns Page_GetTranscriptMinimal_ Successful Response - * @throws ApiError - */ - public v1TranscriptsList( - data: V1TranscriptsListData = {}, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts", - query: { - source_kind: data.sourceKind, - room_id: data.roomId, - search_term: data.searchTerm, - page: data.page, - size: data.size, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcripts Create - * @param data The data for the request. - * @param data.requestBody - * @returns GetTranscript Successful Response - * @throws ApiError - */ - public v1TranscriptsCreate( - data: V1TranscriptsCreateData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts", - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcripts Search - * Full-text search across transcript titles and content. - * @param data The data for the request. - * @param data.q Search query text - * @param data.limit Results per page - * @param data.offset Number of results to skip - * @param data.roomId - * @param data.sourceKind - * @returns SearchResponse Successful Response - * @throws ApiError - */ - public v1TranscriptsSearch( - data: V1TranscriptsSearchData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/search", - query: { - q: data.q, - limit: data.limit, - offset: data.offset, - room_id: data.roomId, - source_kind: data.sourceKind, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get - * @param data The data for the request. - * @param data.transcriptId - * @returns GetTranscript Successful Response - * @throws ApiError - */ - public v1TranscriptGet( - data: V1TranscriptGetData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Update - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns GetTranscript Successful Response - * @throws ApiError - */ - public v1TranscriptUpdate( - data: V1TranscriptUpdateData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/transcripts/{transcript_id}", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Delete - * @param data The data for the request. - * @param data.transcriptId - * @returns DeletionStatus Successful Response - * @throws ApiError - */ - public v1TranscriptDelete( - data: V1TranscriptDeleteData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "DELETE", - url: "/v1/transcripts/{transcript_id}", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Topics - * @param data The data for the request. - * @param data.transcriptId - * @returns GetTranscriptTopic Successful Response - * @throws ApiError - */ - public v1TranscriptGetTopics( - data: V1TranscriptGetTopicsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/topics", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Topics With Words - * @param data The data for the request. - * @param data.transcriptId - * @returns GetTranscriptTopicWithWords Successful Response - * @throws ApiError - */ - public v1TranscriptGetTopicsWithWords( - data: V1TranscriptGetTopicsWithWordsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/topics/with-words", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Topics With Words Per Speaker - * @param data The data for the request. - * @param data.transcriptId - * @param data.topicId - * @returns GetTranscriptTopicWithWordsPerSpeaker Successful Response - * @throws ApiError - */ - public v1TranscriptGetTopicsWithWordsPerSpeaker( - data: V1TranscriptGetTopicsWithWordsPerSpeakerData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker", - path: { - transcript_id: data.transcriptId, - topic_id: data.topicId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Post To Zulip - * @param data The data for the request. - * @param data.transcriptId - * @param data.stream - * @param data.topic - * @param data.includeTopics - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptPostToZulip( - data: V1TranscriptPostToZulipData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/zulip", - path: { - transcript_id: data.transcriptId, - }, - query: { - stream: data.stream, - topic: data.topic, - include_topics: data.includeTopics, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Audio Mp3 - * @param data The data for the request. - * @param data.transcriptId - * @param data.token - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptHeadAudioMp3( - data: V1TranscriptHeadAudioMp3Data, - ): CancelablePromise { - return this.httpRequest.request({ - method: "HEAD", - url: "/v1/transcripts/{transcript_id}/audio/mp3", - path: { - transcript_id: data.transcriptId, - }, - query: { - token: data.token, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Audio Mp3 - * @param data The data for the request. - * @param data.transcriptId - * @param data.token - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptGetAudioMp3( - data: V1TranscriptGetAudioMp3Data, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/audio/mp3", - path: { - transcript_id: data.transcriptId, - }, - query: { - token: data.token, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Audio Waveform - * @param data The data for the request. - * @param data.transcriptId - * @returns AudioWaveform Successful Response - * @throws ApiError - */ - public v1TranscriptGetAudioWaveform( - data: V1TranscriptGetAudioWaveformData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/audio/waveform", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Participants - * @param data The data for the request. - * @param data.transcriptId - * @returns Participant Successful Response - * @throws ApiError - */ - public v1TranscriptGetParticipants( - data: V1TranscriptGetParticipantsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/participants", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Add Participant - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns Participant Successful Response - * @throws ApiError - */ - public v1TranscriptAddParticipant( - data: V1TranscriptAddParticipantData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/participants", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Participant - * @param data The data for the request. - * @param data.transcriptId - * @param data.participantId - * @returns Participant Successful Response - * @throws ApiError - */ - public v1TranscriptGetParticipant( - data: V1TranscriptGetParticipantData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/participants/{participant_id}", - path: { - transcript_id: data.transcriptId, - participant_id: data.participantId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Update Participant - * @param data The data for the request. - * @param data.transcriptId - * @param data.participantId - * @param data.requestBody - * @returns Participant Successful Response - * @throws ApiError - */ - public v1TranscriptUpdateParticipant( - data: V1TranscriptUpdateParticipantData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/transcripts/{transcript_id}/participants/{participant_id}", - path: { - transcript_id: data.transcriptId, - participant_id: data.participantId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Delete Participant - * @param data The data for the request. - * @param data.transcriptId - * @param data.participantId - * @returns DeletionStatus Successful Response - * @throws ApiError - */ - public v1TranscriptDeleteParticipant( - data: V1TranscriptDeleteParticipantData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "DELETE", - url: "/v1/transcripts/{transcript_id}/participants/{participant_id}", - path: { - transcript_id: data.transcriptId, - participant_id: data.participantId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Assign Speaker - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns SpeakerAssignmentStatus Successful Response - * @throws ApiError - */ - public v1TranscriptAssignSpeaker( - data: V1TranscriptAssignSpeakerData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/transcripts/{transcript_id}/speaker/assign", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Merge Speaker - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns SpeakerAssignmentStatus Successful Response - * @throws ApiError - */ - public v1TranscriptMergeSpeaker( - data: V1TranscriptMergeSpeakerData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/transcripts/{transcript_id}/speaker/merge", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Record Upload - * @param data The data for the request. - * @param data.transcriptId - * @param data.chunkNumber - * @param data.totalChunks - * @param data.formData - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptRecordUpload( - data: V1TranscriptRecordUploadData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/record/upload", - path: { - transcript_id: data.transcriptId, - }, - query: { - chunk_number: data.chunkNumber, - total_chunks: data.totalChunks, - }, - formData: data.formData, - mediaType: "multipart/form-data", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Websocket Events - * @param data The data for the request. - * @param data.transcriptId - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptGetWebsocketEvents( - data: V1TranscriptGetWebsocketEventsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/events", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Record Webrtc - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptRecordWebrtc( - data: V1TranscriptRecordWebrtcData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/record/webrtc", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Process - * @param data The data for the request. - * @param data.transcriptId - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptProcess( - data: V1TranscriptProcessData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/process", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * User Me - * @returns unknown Successful Response - * @throws ApiError - */ - public v1UserMe(): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/me", - }); - } - - /** - * Zulip Get Streams - * Get all Zulip streams. - * @returns Stream Successful Response - * @throws ApiError - */ - public v1ZulipGetStreams(): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/zulip/streams", - }); - } - - /** - * Zulip Get Topics - * Get all topics for a specific Zulip stream. - * @param data The data for the request. - * @param data.streamId - * @returns Topic Successful Response - * @throws ApiError - */ - public v1ZulipGetTopics( - data: V1ZulipGetTopicsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/zulip/streams/{stream_id}/topics", - path: { - stream_id: data.streamId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Whereby Webhook - * @param data The data for the request. - * @param data.requestBody - * @returns unknown Successful Response - * @throws ApiError - */ - public v1WherebyWebhook( - data: V1WherebyWebhookData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/whereby", - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } -} diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts deleted file mode 100644 index 9eae96a0..00000000 --- a/www/app/api/types.gen.ts +++ /dev/null @@ -1,1076 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type AudioWaveform = { - data: Array; -}; - -export type Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post = - { - chunk: Blob | File; - }; - -export type CreateParticipant = { - speaker?: number | null; - name: string; -}; - -export type CreateRoom = { - name: string; - zulip_auto_post: boolean; - zulip_stream: string; - zulip_topic: string; - is_locked: boolean; - room_mode: string; - recording_type: string; - recording_trigger: string; - is_shared: boolean; -}; - -export type CreateTranscript = { - name: string; - source_language?: string; - target_language?: string; -}; - -export type DeletionStatus = { - status: string; -}; - -export type GetTranscript = { - id: string; - user_id: string | null; - name: string; - status: string; - locked: boolean; - duration: number; - title: string | null; - short_summary: string | null; - long_summary: string | null; - created_at: string; - share_mode?: string; - source_language: string | null; - target_language: string | null; - reviewed: boolean; - meeting_id: string | null; - source_kind: SourceKind; - room_id?: string | null; - room_name?: string | null; - audio_deleted?: boolean | null; - participants: Array | null; -}; - -export type GetTranscriptMinimal = { - id: string; - user_id: string | null; - name: string; - status: string; - locked: boolean; - duration: number; - title: string | null; - short_summary: string | null; - long_summary: string | null; - created_at: string; - share_mode?: string; - source_language: string | null; - target_language: string | null; - reviewed: boolean; - meeting_id: string | null; - source_kind: SourceKind; - room_id?: string | null; - room_name?: string | null; - audio_deleted?: boolean | null; -}; - -export type GetTranscriptSegmentTopic = { - text: string; - start: number; - speaker: number; -}; - -export type GetTranscriptTopic = { - id: string; - title: string; - summary: string; - timestamp: number; - duration: number | null; - transcript: string; - segments?: Array; -}; - -export type GetTranscriptTopicWithWords = { - id: string; - title: string; - summary: string; - timestamp: number; - duration: number | null; - transcript: string; - segments?: Array; - words?: Array; -}; - -export type GetTranscriptTopicWithWordsPerSpeaker = { - id: string; - title: string; - summary: string; - timestamp: number; - duration: number | null; - transcript: string; - segments?: Array; - words_per_speaker?: Array; -}; - -export type HTTPValidationError = { - detail?: Array; -}; - -export type Meeting = { - id: string; - room_name: string; - room_url: string; - host_room_url: string; - start_date: string; - end_date: string; - recording_type?: "none" | "local" | "cloud"; -}; - -export type recording_type = "none" | "local" | "cloud"; - -export type MeetingConsentRequest = { - consent_given: boolean; -}; - -export type Page_GetTranscriptMinimal_ = { - items: Array; - total?: number | null; - page: number | null; - size: number | null; - pages?: number | null; -}; - -export type Page_Room_ = { - items: Array; - total?: number | null; - page: number | null; - size: number | null; - pages?: number | null; -}; - -export type Participant = { - id: string; - speaker: number | null; - name: string; -}; - -export type Room = { - id: string; - name: string; - user_id: string; - created_at: string; - zulip_auto_post: boolean; - zulip_stream: string; - zulip_topic: string; - is_locked: boolean; - room_mode: string; - recording_type: string; - recording_trigger: string; - is_shared: boolean; -}; - -export type RtcOffer = { - sdp: string; - type: string; -}; - -export type SearchResponse = { - results: Array; - /** - * Total number of search results - */ - total: number; - /** - * Search query text - */ - query: string; - /** - * Results per page - */ - limit: number; - /** - * Number of results to skip - */ - offset: number; -}; - -/** - * Public search result model with computed fields. - */ -export type SearchResult = { - id: string; - title?: string | null; - user_id?: string | null; - room_id?: string | null; - room_name?: string | null; - source_kind: SourceKind; - created_at: string; - status: string; - rank: number; - /** - * Duration in seconds - */ - duration: number | null; - /** - * Text snippets around search matches - */ - search_snippets: Array; - /** - * Total number of matches found in the transcript - */ - total_match_count?: number; -}; - -export type SourceKind = "room" | "live" | "file"; - -export type SpeakerAssignment = { - speaker?: number | null; - participant?: string | null; - timestamp_from: number; - timestamp_to: number; -}; - -export type SpeakerAssignmentStatus = { - status: string; -}; - -export type SpeakerMerge = { - speaker_from: number; - speaker_to: number; -}; - -export type SpeakerWords = { - speaker: number; - words: Array; -}; - -export type Stream = { - stream_id: number; - name: string; -}; - -export type Topic = { - name: string; -}; - -export type TranscriptParticipant = { - id?: string; - speaker: number | null; - name: string; -}; - -export type UpdateParticipant = { - speaker?: number | null; - name?: string | null; -}; - -export type UpdateRoom = { - name: string; - zulip_auto_post: boolean; - zulip_stream: string; - zulip_topic: string; - is_locked: boolean; - room_mode: string; - recording_type: string; - recording_trigger: string; - is_shared: boolean; -}; - -export type UpdateTranscript = { - name?: string | null; - locked?: boolean | null; - title?: string | null; - short_summary?: string | null; - long_summary?: string | null; - share_mode?: "public" | "semi-private" | "private" | null; - participants?: Array | null; - reviewed?: boolean | null; - audio_deleted?: boolean | null; -}; - -export type UserInfo = { - sub: string; - email: string | null; - email_verified: boolean | null; -}; - -export type ValidationError = { - loc: Array; - msg: string; - type: string; -}; - -export type WherebyWebhookEvent = { - apiVersion: string; - id: string; - createdAt: string; - type: string; - data: { - [key: string]: unknown; - }; -}; - -export type Word = { - text: string; - /** - * Time in seconds with float part - */ - start: number; - /** - * Time in seconds with float part - */ - end: number; - speaker?: number; -}; - -export type MetricsResponse = unknown; - -export type V1MeetingAudioConsentData = { - meetingId: string; - requestBody: MeetingConsentRequest; -}; - -export type V1MeetingAudioConsentResponse = unknown; - -export type V1RoomsListData = { - /** - * Page number - */ - page?: number; - /** - * Page size - */ - size?: number; -}; - -export type V1RoomsListResponse = Page_Room_; - -export type V1RoomsCreateData = { - requestBody: CreateRoom; -}; - -export type V1RoomsCreateResponse = Room; - -export type V1RoomsUpdateData = { - requestBody: UpdateRoom; - roomId: string; -}; - -export type V1RoomsUpdateResponse = Room; - -export type V1RoomsDeleteData = { - roomId: string; -}; - -export type V1RoomsDeleteResponse = DeletionStatus; - -export type V1RoomsCreateMeetingData = { - roomName: string; -}; - -export type V1RoomsCreateMeetingResponse = Meeting; - -export type V1TranscriptsListData = { - /** - * Page number - */ - page?: number; - roomId?: string | null; - searchTerm?: string | null; - /** - * Page size - */ - size?: number; - sourceKind?: SourceKind | null; -}; - -export type V1TranscriptsListResponse = Page_GetTranscriptMinimal_; - -export type V1TranscriptsCreateData = { - requestBody: CreateTranscript; -}; - -export type V1TranscriptsCreateResponse = GetTranscript; - -export type V1TranscriptsSearchData = { - /** - * Results per page - */ - limit?: number; - /** - * Number of results to skip - */ - offset?: number; - /** - * Search query text - */ - q: string; - roomId?: string | null; - sourceKind?: SourceKind | null; -}; - -export type V1TranscriptsSearchResponse = SearchResponse; - -export type V1TranscriptGetData = { - transcriptId: string; -}; - -export type V1TranscriptGetResponse = GetTranscript; - -export type V1TranscriptUpdateData = { - requestBody: UpdateTranscript; - transcriptId: string; -}; - -export type V1TranscriptUpdateResponse = GetTranscript; - -export type V1TranscriptDeleteData = { - transcriptId: string; -}; - -export type V1TranscriptDeleteResponse = DeletionStatus; - -export type V1TranscriptGetTopicsData = { - transcriptId: string; -}; - -export type V1TranscriptGetTopicsResponse = Array; - -export type V1TranscriptGetTopicsWithWordsData = { - transcriptId: string; -}; - -export type V1TranscriptGetTopicsWithWordsResponse = - Array; - -export type V1TranscriptGetTopicsWithWordsPerSpeakerData = { - topicId: string; - transcriptId: string; -}; - -export type V1TranscriptGetTopicsWithWordsPerSpeakerResponse = - GetTranscriptTopicWithWordsPerSpeaker; - -export type V1TranscriptPostToZulipData = { - includeTopics: boolean; - stream: string; - topic: string; - transcriptId: string; -}; - -export type V1TranscriptPostToZulipResponse = unknown; - -export type V1TranscriptHeadAudioMp3Data = { - token?: string | null; - transcriptId: string; -}; - -export type V1TranscriptHeadAudioMp3Response = unknown; - -export type V1TranscriptGetAudioMp3Data = { - token?: string | null; - transcriptId: string; -}; - -export type V1TranscriptGetAudioMp3Response = unknown; - -export type V1TranscriptGetAudioWaveformData = { - transcriptId: string; -}; - -export type V1TranscriptGetAudioWaveformResponse = AudioWaveform; - -export type V1TranscriptGetParticipantsData = { - transcriptId: string; -}; - -export type V1TranscriptGetParticipantsResponse = Array; - -export type V1TranscriptAddParticipantData = { - requestBody: CreateParticipant; - transcriptId: string; -}; - -export type V1TranscriptAddParticipantResponse = Participant; - -export type V1TranscriptGetParticipantData = { - participantId: string; - transcriptId: string; -}; - -export type V1TranscriptGetParticipantResponse = Participant; - -export type V1TranscriptUpdateParticipantData = { - participantId: string; - requestBody: UpdateParticipant; - transcriptId: string; -}; - -export type V1TranscriptUpdateParticipantResponse = Participant; - -export type V1TranscriptDeleteParticipantData = { - participantId: string; - transcriptId: string; -}; - -export type V1TranscriptDeleteParticipantResponse = DeletionStatus; - -export type V1TranscriptAssignSpeakerData = { - requestBody: SpeakerAssignment; - transcriptId: string; -}; - -export type V1TranscriptAssignSpeakerResponse = SpeakerAssignmentStatus; - -export type V1TranscriptMergeSpeakerData = { - requestBody: SpeakerMerge; - transcriptId: string; -}; - -export type V1TranscriptMergeSpeakerResponse = SpeakerAssignmentStatus; - -export type V1TranscriptRecordUploadData = { - chunkNumber: number; - formData: Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post; - totalChunks: number; - transcriptId: string; -}; - -export type V1TranscriptRecordUploadResponse = unknown; - -export type V1TranscriptGetWebsocketEventsData = { - transcriptId: string; -}; - -export type V1TranscriptGetWebsocketEventsResponse = unknown; - -export type V1TranscriptRecordWebrtcData = { - requestBody: RtcOffer; - transcriptId: string; -}; - -export type V1TranscriptRecordWebrtcResponse = unknown; - -export type V1TranscriptProcessData = { - transcriptId: string; -}; - -export type V1TranscriptProcessResponse = unknown; - -export type V1UserMeResponse = UserInfo | null; - -export type V1ZulipGetStreamsResponse = Array; - -export type V1ZulipGetTopicsData = { - streamId: number; -}; - -export type V1ZulipGetTopicsResponse = Array; - -export type V1WherebyWebhookData = { - requestBody: WherebyWebhookEvent; -}; - -export type V1WherebyWebhookResponse = unknown; - -export type $OpenApiTs = { - "/metrics": { - get: { - res: { - /** - * Successful Response - */ - 200: unknown; - }; - }; - }; - "/v1/meetings/{meeting_id}/consent": { - post: { - req: V1MeetingAudioConsentData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/rooms": { - get: { - req: V1RoomsListData; - res: { - /** - * Successful Response - */ - 200: Page_Room_; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - post: { - req: V1RoomsCreateData; - res: { - /** - * Successful Response - */ - 200: Room; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/rooms/{room_id}": { - patch: { - req: V1RoomsUpdateData; - res: { - /** - * Successful Response - */ - 200: Room; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - delete: { - req: V1RoomsDeleteData; - res: { - /** - * Successful Response - */ - 200: DeletionStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/rooms/{room_name}/meeting": { - post: { - req: V1RoomsCreateMeetingData; - res: { - /** - * Successful Response - */ - 200: Meeting; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts": { - get: { - req: V1TranscriptsListData; - res: { - /** - * Successful Response - */ - 200: Page_GetTranscriptMinimal_; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - post: { - req: V1TranscriptsCreateData; - res: { - /** - * Successful Response - */ - 200: GetTranscript; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/search": { - get: { - req: V1TranscriptsSearchData; - res: { - /** - * Successful Response - */ - 200: SearchResponse; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}": { - get: { - req: V1TranscriptGetData; - res: { - /** - * Successful Response - */ - 200: GetTranscript; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - patch: { - req: V1TranscriptUpdateData; - res: { - /** - * Successful Response - */ - 200: GetTranscript; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - delete: { - req: V1TranscriptDeleteData; - res: { - /** - * Successful Response - */ - 200: DeletionStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/topics": { - get: { - req: V1TranscriptGetTopicsData; - res: { - /** - * Successful Response - */ - 200: Array; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/topics/with-words": { - get: { - req: V1TranscriptGetTopicsWithWordsData; - res: { - /** - * Successful Response - */ - 200: Array; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker": { - get: { - req: V1TranscriptGetTopicsWithWordsPerSpeakerData; - res: { - /** - * Successful Response - */ - 200: GetTranscriptTopicWithWordsPerSpeaker; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/zulip": { - post: { - req: V1TranscriptPostToZulipData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/audio/mp3": { - head: { - req: V1TranscriptHeadAudioMp3Data; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - get: { - req: V1TranscriptGetAudioMp3Data; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/audio/waveform": { - get: { - req: V1TranscriptGetAudioWaveformData; - res: { - /** - * Successful Response - */ - 200: AudioWaveform; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/participants": { - get: { - req: V1TranscriptGetParticipantsData; - res: { - /** - * Successful Response - */ - 200: Array; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - post: { - req: V1TranscriptAddParticipantData; - res: { - /** - * Successful Response - */ - 200: Participant; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/participants/{participant_id}": { - get: { - req: V1TranscriptGetParticipantData; - res: { - /** - * Successful Response - */ - 200: Participant; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - patch: { - req: V1TranscriptUpdateParticipantData; - res: { - /** - * Successful Response - */ - 200: Participant; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - delete: { - req: V1TranscriptDeleteParticipantData; - res: { - /** - * Successful Response - */ - 200: DeletionStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/speaker/assign": { - patch: { - req: V1TranscriptAssignSpeakerData; - res: { - /** - * Successful Response - */ - 200: SpeakerAssignmentStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/speaker/merge": { - patch: { - req: V1TranscriptMergeSpeakerData; - res: { - /** - * Successful Response - */ - 200: SpeakerAssignmentStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/record/upload": { - post: { - req: V1TranscriptRecordUploadData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/events": { - get: { - req: V1TranscriptGetWebsocketEventsData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/record/webrtc": { - post: { - req: V1TranscriptRecordWebrtcData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/process": { - post: { - req: V1TranscriptProcessData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/me": { - get: { - res: { - /** - * Successful Response - */ - 200: UserInfo | null; - }; - }; - }; - "/v1/zulip/streams": { - get: { - res: { - /** - * Successful Response - */ - 200: Array; - }; - }; - }; - "/v1/zulip/streams/{stream_id}/topics": { - get: { - req: V1ZulipGetTopicsData; - res: { - /** - * Successful Response - */ - 200: Array; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/whereby": { - post: { - req: V1WherebyWebhookData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; -}; diff --git a/www/app/api/urls.ts b/www/app/api/urls.ts index bd0a910c..89ce5af8 100644 --- a/www/app/api/urls.ts +++ b/www/app/api/urls.ts @@ -1,2 +1 @@ -// TODO better connection with generated schema; it's duplication export const RECORD_A_MEETING_URL = "/transcripts/new" as const; diff --git a/www/app/components/MeetingMinimalHeader.tsx b/www/app/components/MeetingMinimalHeader.tsx new file mode 100644 index 00000000..fe08c9d6 --- /dev/null +++ b/www/app/components/MeetingMinimalHeader.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { Flex, Link, Button, Text, HStack } from "@chakra-ui/react"; +import NextLink from "next/link"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { roomUrl } from "../lib/routes"; +import { NonEmptyString } from "../lib/utils"; + +interface MeetingMinimalHeaderProps { + roomName: NonEmptyString; + displayName?: string; + showLeaveButton?: boolean; + onLeave?: () => void; + showCreateButton?: boolean; + onCreateMeeting?: () => void; + isCreatingMeeting?: boolean; +} + +export default function MeetingMinimalHeader({ + roomName, + displayName, + showLeaveButton = true, + onLeave, + showCreateButton = false, + onCreateMeeting, + isCreatingMeeting = false, +}: MeetingMinimalHeaderProps) { + const router = useRouter(); + + const handleLeaveMeeting = () => { + if (onLeave) { + onLeave(); + } else { + router.push(roomUrl(roomName)); + } + }; + + const roomTitle = displayName + ? displayName.endsWith("'s") || displayName.endsWith("s") + ? `${displayName} Room` + : `${displayName}'s Room` + : `${roomName} Room`; + + return ( + + {/* Logo and Room Context */} + + + Reflector + + + {roomTitle} + + + + {/* Action Buttons */} + + {showCreateButton && onCreateMeeting && ( + + )} + {showLeaveButton && ( + + )} + + + ); +} diff --git a/www/app/domainContext.tsx b/www/app/domainContext.tsx deleted file mode 100644 index 7e415f1c..00000000 --- a/www/app/domainContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; -import { createContext, useContext, useEffect, useState } from "react"; -import { DomainConfig } from "./lib/edgeConfig"; - -type DomainContextType = Omit; - -export const DomainContext = createContext({ - features: { - requireLogin: false, - privacy: true, - browse: false, - sendToZulip: false, - }, - api_url: "", - websocket_url: "", -}); - -export const DomainContextProvider = ({ - config, - children, -}: { - config: DomainConfig; - children: any; -}) => { - const [context, setContext] = useState(); - - useEffect(() => { - if (!config) return; - const { auth_callback_url, ...others } = config; - setContext(others); - }, [config]); - - if (!context) return; - - return ( - {children} - ); -}; - -// Get feature config client-side with -export const featureEnabled = ( - featureName: "requireLogin" | "privacy" | "browse" | "sendToZulip", -) => { - const context = useContext(DomainContext); - - return context.features[featureName] as boolean | undefined; -}; - -// Get config server-side (out of react) : see lib/edgeConfig. diff --git a/www/app/layout.tsx b/www/app/layout.tsx index f73b8813..5fc01ebe 100644 --- a/www/app/layout.tsx +++ b/www/app/layout.tsx @@ -1,14 +1,15 @@ import "./styles/globals.scss"; import { Metadata, Viewport } from "next"; import { Poppins } from "next/font/google"; -import SessionProvider from "./lib/SessionProvider"; import { ErrorProvider } from "./(errors)/errorContext"; import ErrorMessage from "./(errors)/errorMessage"; -import { DomainContextProvider } from "./domainContext"; import { RecordingConsentProvider } from "./recordingConsentContext"; -import { getConfig } from "./lib/edgeConfig"; import { ErrorBoundary } from "@sentry/nextjs"; import { Providers } from "./providers"; +import { getNextEnvVar } from "./lib/nextBuild"; +import { getClientEnv } from "./lib/clientEnv"; + +export const dynamic = "force-dynamic"; const poppins = Poppins({ subsets: ["latin"], @@ -23,8 +24,11 @@ export const viewport: Viewport = { maximumScale: 1, }; +const SITE_URL = getNextEnvVar("SITE_URL"); +const env = getClientEnv(); + export const metadata: Metadata = { - metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!), + metadataBase: new URL(SITE_URL), title: { template: "%s – Reflector", default: "Reflector - AI-Powered Meeting Transcriptions by Monadical", @@ -69,23 +73,18 @@ export default async function RootLayout({ }: { children: React.ReactNode; }) { - const config = await getConfig(); - return ( - - - - - "something went really wrong"

}> - - - {children} - -
-
-
-
+ + "something went really wrong"

}> + + + {children} + +
); diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx new file mode 100644 index 00000000..e1eabf99 --- /dev/null +++ b/www/app/lib/AuthProvider.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { useSession as useNextAuthSession } from "next-auth/react"; +import { signOut, signIn } from "next-auth/react"; +import { configureApiAuth } from "./apiClient"; +import { assertCustomSession, CustomSession } from "./types"; +import { Session } from "next-auth"; +import { SessionAutoRefresh } from "./SessionAutoRefresh"; +import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth"; +import { assertExists } from "./utils"; +import { featureEnabled } from "./features"; + +type AuthContextType = ( + | { status: "loading" } + | { status: "refreshing"; user: CustomSession["user"] } + | { status: "unauthenticated"; error?: string } + | { + status: "authenticated"; + accessToken: string; + accessTokenExpires: number; + user: CustomSession["user"]; + } +) & { + update: () => Promise; + signIn: typeof signIn; + signOut: typeof signOut; +}; + +const AuthContext = createContext(undefined); +const isAuthEnabled = featureEnabled("requireLogin"); + +const noopAuthContext: AuthContextType = { + status: "unauthenticated", + update: async () => { + return null; + }, + signIn: async () => { + throw new Error("signIn not supposed to be called"); + }, + signOut: async () => { + throw new Error("signOut not supposed to be called"); + }, +}; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const { data: session, status, update } = useNextAuthSession(); + + const contextValue: AuthContextType = isAuthEnabled + ? { + ...(() => { + switch (status) { + case "loading": { + const sessionIsHere = !!session; + // actually exists sometimes; nextAuth types are something else + switch (sessionIsHere as boolean) { + case false: { + return { status }; + } + case true: { + return { + status: "refreshing" as const, + user: assertCustomSession( + assertExists(session as unknown as Session), + ).user, + }; + } + default: { + throw new Error("unreachable"); + } + } + } + case "authenticated": { + const customSession = assertCustomSession(session); + if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) { + // token had expired but next auth still returns "authenticated" so show user unauthenticated state + return { + status: "unauthenticated" as const, + }; + } else if (customSession?.accessToken) { + return { + status, + accessToken: customSession.accessToken, + accessTokenExpires: customSession.accessTokenExpires, + user: customSession.user, + }; + } else { + console.warn( + "illegal state: authenticated but have no session/or access token. ignoring", + ); + return { status: "unauthenticated" as const }; + } + } + case "unauthenticated": { + return { status: "unauthenticated" as const }; + } + default: { + const _: never = status; + throw new Error("unreachable"); + } + } + })(), + update, + signIn, + signOut, + } + : noopAuthContext; + + // not useEffect, we need it ASAP + // apparently, still no guarantee this code runs before mutations are fired + configureApiAuth( + contextValue.status === "authenticated" + ? contextValue.accessToken + : contextValue.status === "loading" + ? undefined + : null, + ); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx index 1e230d6c..6b26077d 100644 --- a/www/app/lib/SessionAutoRefresh.tsx +++ b/www/app/lib/SessionAutoRefresh.tsx @@ -1,5 +1,5 @@ /** - * This is a custom hook that automatically refreshes the session when the access token is about to expire. + * This is a custom provider that automatically refreshes the session when the access token is about to expire. * When communicating with the reflector API, we need to ensure that the access token is always valid. * * We could have implemented that as an interceptor on the API client, but not everything is using the @@ -7,30 +7,35 @@ */ "use client"; -import { useSession } from "next-auth/react"; import { useEffect } from "react"; -import { CustomSession } from "./types"; +import { useAuth } from "./AuthProvider"; +import { shouldRefreshToken } from "./auth"; -export function SessionAutoRefresh({ - children, - refreshInterval = 20 /* seconds */, -}) { - const { data: session, update } = useSession(); - const customSession = session as CustomSession; - const accessTokenExpires = customSession?.accessTokenExpires; +export function SessionAutoRefresh({ children }) { + const auth = useAuth(); + + const accessTokenExpires = + auth.status === "authenticated" ? auth.accessTokenExpires : null; useEffect(() => { + // technical value for how often the setInterval will be polling news - not too fast (no spam in case of errors) + // and not too slow (debuggable) + const INTERVAL_REFRESH_MS = 5000; const interval = setInterval(() => { - if (accessTokenExpires) { - const timeLeft = accessTokenExpires - Date.now(); - if (timeLeft < refreshInterval * 1000) { - update(); - } + if (accessTokenExpires === null) return; + if (shouldRefreshToken(accessTokenExpires)) { + auth + .update() + .then(() => {}) + .catch((e) => { + // note: 401 won't be considered error here + console.error("error refreshing auth token", e); + }); } - }, refreshInterval * 1000); + }, INTERVAL_REFRESH_MS); return () => clearInterval(interval); - }, [accessTokenExpires, refreshInterval, update]); + }, [accessTokenExpires, auth.update]); return children; } diff --git a/www/app/lib/SessionProvider.tsx b/www/app/lib/SessionProvider.tsx deleted file mode 100644 index 9c95fbc8..00000000 --- a/www/app/lib/SessionProvider.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; -import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; -import { SessionAutoRefresh } from "./SessionAutoRefresh"; - -export default function SessionProvider({ children }) { - return ( - - {children} - - ); -} diff --git a/www/app/lib/UserEventsProvider.tsx b/www/app/lib/UserEventsProvider.tsx new file mode 100644 index 00000000..89ec5a11 --- /dev/null +++ b/www/app/lib/UserEventsProvider.tsx @@ -0,0 +1,180 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { WEBSOCKET_URL } from "./apiClient"; +import { useAuth } from "./AuthProvider"; +import { z } from "zod"; +import { invalidateTranscriptLists, TRANSCRIPT_SEARCH_URL } from "./apiHooks"; + +const UserEvent = z.object({ + event: z.string(), +}); + +type UserEvent = z.TypeOf; + +class UserEventsStore { + private socket: WebSocket | null = null; + private listeners: Set<(event: MessageEvent) => void> = new Set(); + private closeTimeoutId: number | null = null; + private isConnecting = false; + + ensureConnection(url: string, subprotocols?: string[]) { + if (typeof window === "undefined") return; + if (this.closeTimeoutId !== null) { + clearTimeout(this.closeTimeoutId); + this.closeTimeoutId = null; + } + if (this.isConnecting) return; + if ( + this.socket && + (this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING) + ) { + return; + } + this.isConnecting = true; + const ws = new WebSocket(url, subprotocols || []); + this.socket = ws; + ws.onmessage = (event: MessageEvent) => { + this.listeners.forEach((listener) => { + try { + listener(event); + } catch (err) { + console.error("UserEvents listener error", err); + } + }); + }; + ws.onopen = () => { + if (this.socket === ws) this.isConnecting = false; + }; + ws.onclose = () => { + if (this.socket === ws) { + this.socket = null; + this.isConnecting = false; + } + }; + ws.onerror = () => { + if (this.socket === ws) this.isConnecting = false; + }; + } + + subscribe(listener: (event: MessageEvent) => void): () => void { + this.listeners.add(listener); + if (this.closeTimeoutId !== null) { + clearTimeout(this.closeTimeoutId); + this.closeTimeoutId = null; + } + return () => { + this.listeners.delete(listener); + if (this.listeners.size === 0) { + this.closeTimeoutId = window.setTimeout(() => { + if (this.socket) { + try { + this.socket.close(); + } catch (err) { + console.warn("Error closing user events socket", err); + } + } + this.socket = null; + this.closeTimeoutId = null; + }, 1000); + } + }; + } +} + +const sharedStore = new UserEventsStore(); + +export function UserEventsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const auth = useAuth(); + const queryClient = useQueryClient(); + const tokenRef = useRef(null); + const detachRef = useRef<(() => void) | null>(null); + + useEffect(() => { + // Only tear down when the user is truly unauthenticated + if (auth.status === "unauthenticated") { + if (detachRef.current) { + try { + detachRef.current(); + } catch (err) { + console.warn("Error detaching UserEvents listener", err); + } + detachRef.current = null; + } + tokenRef.current = null; + return; + } + + // During loading/refreshing, keep the existing connection intact + if (auth.status !== "authenticated") { + return; + } + + // Authenticated: pin the initial token for the lifetime of this WS connection + if (!tokenRef.current && auth.accessToken) { + tokenRef.current = auth.accessToken; + } + const pinnedToken = tokenRef.current; + const url = `${WEBSOCKET_URL}/v1/events`; + + // Ensure a single shared connection + sharedStore.ensureConnection( + url, + pinnedToken ? ["bearer", pinnedToken] : undefined, + ); + + // Subscribe once; avoid re-subscribing during transient status changes + if (!detachRef.current) { + const onMessage = (event: MessageEvent) => { + try { + const msg = UserEvent.parse(JSON.parse(event.data)); + const eventName = msg.event; + + const invalidateList = () => invalidateTranscriptLists(queryClient); + + switch (eventName) { + case "TRANSCRIPT_CREATED": + case "TRANSCRIPT_DELETED": + case "TRANSCRIPT_STATUS": + case "TRANSCRIPT_FINAL_TITLE": + case "TRANSCRIPT_DURATION": + invalidateList().then(() => {}); + break; + + default: + // Ignore other content events for list updates + break; + } + } catch (err) { + console.warn("Invalid user event message", event.data); + } + }; + + const unsubscribe = sharedStore.subscribe(onMessage); + detachRef.current = unsubscribe; + } + }, [auth.status, queryClient]); + + // On unmount, detach the listener and clear the pinned token + useEffect(() => { + return () => { + if (detachRef.current) { + try { + detachRef.current(); + } catch (err) { + console.warn("Error detaching UserEvents listener on unmount", err); + } + detachRef.current = null; + } + tokenRef.current = null; + }; + }, []); + + return <>{children}; +} diff --git a/www/app/lib/WherebyWebinarEmbed.tsx b/www/app/lib/WherebyWebinarEmbed.tsx index 5bfef554..5526cca2 100644 --- a/www/app/lib/WherebyWebinarEmbed.tsx +++ b/www/app/lib/WherebyWebinarEmbed.tsx @@ -4,16 +4,16 @@ import "@whereby.com/browser-sdk/embed"; import { Box, Button, HStack, Text, Link } from "@chakra-ui/react"; import { toaster } from "../components/ui/toaster"; -interface WherebyEmbedProps { +interface WherebyWebinarEmbedProps { roomUrl: string; onLeave?: () => void; } -// currently used for webinars only +// used for webinars only export default function WherebyWebinarEmbed({ roomUrl, onLeave, -}: WherebyEmbedProps) { +}: WherebyWebinarEmbedProps) { const wherebyRef = useRef(null); // TODO extract common toast logic / styles to be used by consent toast on normal rooms diff --git a/www/app/lib/__tests__/redisTokenCache.test.ts b/www/app/lib/__tests__/redisTokenCache.test.ts new file mode 100644 index 00000000..8ca8e8a1 --- /dev/null +++ b/www/app/lib/__tests__/redisTokenCache.test.ts @@ -0,0 +1,85 @@ +import { + getTokenCache, + setTokenCache, + deleteTokenCache, + TokenCacheEntry, + KV, +} from "../redisTokenCache"; + +const mockKV: KV & { + clear: () => void; +} = (() => { + const data = new Map(); + return { + async get(key: string): Promise { + return data.get(key) || null; + }, + + async setex(key: string, seconds_: number, value: string): Promise<"OK"> { + data.set(key, value); + return "OK"; + }, + + async del(key: string): Promise { + const existed = data.has(key); + data.delete(key); + return existed ? 1 : 0; + }, + + clear() { + data.clear(); + }, + }; +})(); + +describe("Redis Token Cache", () => { + beforeEach(() => { + mockKV.clear(); + }); + + test("basic write/read - value written equals value read", async () => { + const testKey = "token:test-user-123"; + const testValue: TokenCacheEntry = { + token: { + sub: "test-user-123", + name: "Test User", + email: "test@example.com", + accessToken: "access-token-123", + accessTokenExpires: Date.now() + 3600000, // 1 hour from now + refreshToken: "refresh-token-456", + }, + timestamp: Date.now(), + }; + + await setTokenCache(mockKV, testKey, testValue); + const retrievedValue = await getTokenCache(mockKV, testKey); + + expect(retrievedValue).not.toBeNull(); + expect(retrievedValue).toEqual(testValue); + expect(retrievedValue?.token.accessToken).toBe(testValue.token.accessToken); + expect(retrievedValue?.token.sub).toBe(testValue.token.sub); + expect(retrievedValue?.timestamp).toBe(testValue.timestamp); + }); + + test("get returns null for non-existent key", async () => { + const result = await getTokenCache(mockKV, "non-existent-key"); + expect(result).toBeNull(); + }); + + test("delete removes token from cache", async () => { + const testKey = "token:delete-test"; + const testValue: TokenCacheEntry = { + token: { + accessToken: "test-token", + accessTokenExpires: Date.now() + 3600000, + }, + timestamp: Date.now(), + }; + + await setTokenCache(mockKV, testKey, testValue); + await deleteTokenCache(mockKV, testKey); + + const result = await getTokenCache(mockKV, testKey); + expect(result).toBeNull(); + }); +}); diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx new file mode 100644 index 00000000..442d2f42 --- /dev/null +++ b/www/app/lib/apiClient.tsx @@ -0,0 +1,70 @@ +"use client"; + +import createClient from "openapi-fetch"; +import type { paths } from "../reflector-api"; +import createFetchClient from "openapi-react-query"; +import { parseNonEmptyString } from "./utils"; +import { isBuildPhase } from "./next"; +import { getSession } from "next-auth/react"; +import { assertExtendedToken } from "./types"; +import { getClientEnv } from "./clientEnv"; + +export const API_URL = !isBuildPhase + ? getClientEnv().API_URL + : "http://localhost"; + +export const WEBSOCKET_URL = !isBuildPhase + ? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250" + : "ws://localhost"; + +export const client = createClient({ + baseUrl: API_URL, +}); + +// will assert presence/absence of login initially +const initialSessionPromise = getSession(); + +const waitForAuthTokenDefinitivePresenceOrAbsence = async () => { + const initialSession = await initialSessionPromise; + if (currentAuthToken === undefined) { + currentAuthToken = + initialSession === null + ? null + : assertExtendedToken(initialSession).accessToken; + } + // otherwise already overwritten by external forces + return currentAuthToken; +}; + +client.use({ + async onRequest({ request }) { + const token = await waitForAuthTokenDefinitivePresenceOrAbsence(); + if (token !== null) { + request.headers.set( + "Authorization", + `Bearer ${parseNonEmptyString(token, true, "panic! token is required")}`, + ); + } + // XXX Only set Content-Type if not already set (FormData will set its own boundary) + // This is a work around for uploading file, we're passing a formdata + // but the content type was still application/json + if ( + !request.headers.has("Content-Type") && + !(request.body instanceof FormData) + ) { + request.headers.set("Content-Type", "application/json"); + } + return request; + }, +}); + +export const $api = createFetchClient(client); + +let currentAuthToken: string | null | undefined = undefined; + +// the function contract: lightweight, idempotent +export const configureApiAuth = (token: string | null | undefined) => { + // watch only for the initial loading; "reloading" state assumes token presence/absence + if (token === undefined && currentAuthToken !== undefined) return; + currentAuthToken = token; +}; diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts new file mode 100644 index 00000000..726e5441 --- /dev/null +++ b/www/app/lib/apiHooks.ts @@ -0,0 +1,797 @@ +"use client"; + +import { $api } from "./apiClient"; +import { useError } from "../(errors)/errorContext"; +import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import type { components } from "../reflector-api"; +import { useAuth } from "./AuthProvider"; + +/* + * XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other + * this is either a limitation or incorrect usage of Python json schema generator + * or, limitation or incorrect usage of .d type generator from json schema + * */ + +export const useAuthReady = () => { + const auth = useAuth(); + + return { + isAuthenticated: auth.status === "authenticated", + isLoading: auth.status === "loading", + }; +}; + +export function useRoomsList(page: number = 1) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms", + { + params: { + query: { page }, + }, + }, + { + enabled: isAuthenticated, + }, + ); +} + +type SourceKind = components["schemas"]["SourceKind"]; + +export const TRANSCRIPT_SEARCH_URL = "/v1/transcripts/search" as const; + +export const invalidateTranscriptLists = (queryClient: QueryClient) => + queryClient.invalidateQueries({ + queryKey: ["get", TRANSCRIPT_SEARCH_URL], + }); + +export function useTranscriptsSearch( + q: string = "", + options: { + limit?: number; + offset?: number; + room_id?: string; + source_kind?: SourceKind; + } = {}, +) { + return $api.useQuery( + "get", + TRANSCRIPT_SEARCH_URL, + { + params: { + query: { + q, + limit: options.limit, + offset: options.offset, + room_id: options.room_id, + source_kind: options.source_kind, + }, + }, + }, + { + enabled: true, + }, + ); +} + +export function useTranscriptDelete() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", { + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: ["get", TRANSCRIPT_SEARCH_URL], + }); + }, + onError: (error) => { + setError(error as Error, "There was an error deleting the transcript"); + }, + }); +} + +export function useTranscriptProcess() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/transcripts/{transcript_id}/process", { + onError: (error) => { + setError(error as Error, "There was an error processing the transcript"); + }, + }); +} + +export function useTranscriptGet(transcriptId: string | null) { + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { + transcript_id: transcriptId!, + }, + }, + }, + { + enabled: !!transcriptId, + }, + ); +} + +export function useRoomGet(roomId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms/{room_id}", + { + params: { + path: { room_id: roomId! }, + }, + }, + { + enabled: !!roomId && isAuthenticated, + }, + ); +} + +export function useRoomTestWebhook() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/rooms/{room_id}/webhook/test", { + onError: (error) => { + setError(error as Error, "There was an error testing the webhook"); + }, + }); +} + +export function useRoomCreate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/v1/rooms", { + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the room"); + }, + }); +} + +export function useRoomUpdate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("patch", "/v1/rooms/{room_id}", { + onSuccess: async (room) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }), + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms/{room_id}", { + params: { + path: { + room_id: room.id, + }, + }, + }).queryKey, + }), + ]); + }, + onError: (error) => { + setError(error as Error, "There was an error updating the room"); + }, + }); +} + +export function useRoomDelete() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("delete", "/v1/rooms/{room_id}", { + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error deleting the room"); + }, + }); +} + +export function useZulipStreams() { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/zulip/streams", + {}, + { + enabled: isAuthenticated, + }, + ); +} + +export function useZulipTopics(streamId: number | null) { + const { isAuthenticated } = useAuthReady(); + const enabled = !!streamId && isAuthenticated; + return $api.useQuery( + "get", + "/v1/zulip/streams/{stream_id}/topics", + { + params: { + path: { + stream_id: enabled ? streamId : 0, + }, + }, + }, + { + enabled, + }, + ); +} + +export function useTranscriptUpdate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", { + onSuccess: (data, variables) => { + return queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error updating the transcript"); + }, + }); +} + +export function useTranscriptPostToZulip() { + const { setError } = useError(); + + // @ts-ignore - Zulip endpoint not in OpenAPI spec + return $api.useMutation("post", "/v1/transcripts/{transcript_id}/zulip", { + onError: (error) => { + setError(error as Error, "There was an error posting to Zulip"); + }, + }); +} + +export function useTranscriptUploadAudio() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/record/upload", + { + onSuccess: (data, variables) => { + return queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error uploading the audio file"); + }, + }, + ); +} + +export function useTranscriptWaveform(transcriptId: string | null) { + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/audio/waveform", + { + params: { + path: { transcript_id: transcriptId! }, + }, + }, + { + enabled: !!transcriptId, + }, + ); +} + +export function useTranscriptMP3(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/audio/mp3", + { + params: { + path: { transcript_id: transcriptId! }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useTranscriptTopics(transcriptId: string | null) { + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/topics", + { + params: { + path: { transcript_id: transcriptId! }, + }, + }, + { + enabled: !!transcriptId, + }, + ); +} + +export function useTranscriptTopicsWithWords(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/topics/with-words", + { + params: { + path: { transcript_id: transcriptId! }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useTranscriptTopicsWithWordsPerSpeaker( + transcriptId: string | null, + topicId: string | null, +) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker", + { + params: { + path: { + transcript_id: transcriptId!, + topic_id: topicId!, + }, + }, + }, + { + enabled: !!transcriptId && !!topicId && isAuthenticated, + }, + ); +} + +export function useTranscriptParticipants(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: transcriptId! }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useTranscriptParticipantUpdate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "patch", + "/v1/transcripts/{transcript_id}/participants/{participant_id}", + { + onSuccess: (data, variables) => { + return queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error updating the participant"); + }, + }, + ); +} + +export function useTranscriptParticipantCreate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/participants", + { + onSuccess: (data, variables) => { + return queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the participant"); + }, + }, + ); +} + +export function useTranscriptParticipantDelete() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "delete", + "/v1/transcripts/{transcript_id}/participants/{participant_id}", + { + onSuccess: (data, variables) => { + return queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error deleting the participant"); + }, + }, + ); +} + +export function useTranscriptSpeakerAssign() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "patch", + "/v1/transcripts/{transcript_id}/speaker/assign", + { + onSuccess: (data, variables) => { + return Promise.all([ + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }), + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }), + ]); + }, + onError: (error) => { + setError(error as Error, "There was an error assigning the speaker"); + }, + }, + ); +} + +export function useTranscriptSpeakerMerge() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "patch", + "/v1/transcripts/{transcript_id}/speaker/merge", + { + onSuccess: (data, variables) => { + return Promise.all([ + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }), + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }), + ]); + }, + onError: (error) => { + setError(error as Error, "There was an error merging speakers"); + }, + }, + ); +} + +export function useMeetingAudioConsent() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/meetings/{meeting_id}/consent", { + onError: (error) => { + setError(error as Error, "There was an error recording consent"); + }, + }); +} + +export function useMeetingDeactivate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("patch", `/v1/meetings/{meeting_id}/deactivate`, { + onError: (error) => { + setError(error as Error, "Failed to end meeting"); + }, + onSuccess: () => { + return queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return key.some( + (k) => + typeof k === "string" && + !!MEETING_LIST_PATH_PARTIALS.find((e) => k.includes(e)), + ); + }, + }); + }, + }); +} + +export function useTranscriptWebRTC() { + const { setError } = useError(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/record/webrtc", + { + onError: (error) => { + setError(error as Error, "There was an error with WebRTC connection"); + }, + }, + ); +} + +export function useTranscriptCreate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/v1/transcripts", { + onSuccess: () => { + return queryClient.invalidateQueries({ + queryKey: ["get", TRANSCRIPT_SEARCH_URL], + }); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the transcript"); + }, + }); +} + +export function useRoomsCreateMeeting() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", { + onSuccess: async (data, variables) => { + const roomName = variables.params.path.room_name; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }), + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`, + { + params: { + path: { room_name: roomName }, + }, + }, + ).queryKey, + }), + ]); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the meeting"); + }, + }); +} + +// Calendar integration hooks +export function useRoomGetByName(roomName: string | null) { + return $api.useQuery( + "get", + "/v1/rooms/name/{room_name}", + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName, + }, + ); +} + +export function useRoomUpcomingMeetings(roomName: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings/upcoming" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_UPCOMING_PATH_PARTIAL}`, + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName && isAuthenticated, + }, + ); +} + +const MEETINGS_PATH_PARTIAL = "meetings" as const; +const MEETINGS_ACTIVE_PATH_PARTIAL = `${MEETINGS_PATH_PARTIAL}/active` as const; +const MEETINGS_UPCOMING_PATH_PARTIAL = + `${MEETINGS_PATH_PARTIAL}/upcoming` as const; +const MEETING_LIST_PATH_PARTIALS = [ + MEETINGS_ACTIVE_PATH_PARTIAL, + MEETINGS_UPCOMING_PATH_PARTIAL, +]; + +export function useRoomActiveMeetings(roomName: string | null) { + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`, + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName, + }, + ); +} + +export function useRoomGetMeeting( + roomName: string | null, + meetingId: string | null, +) { + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings/{meeting_id}", + { + params: { + path: { + room_name: roomName!, + meeting_id: meetingId!, + }, + }, + }, + { + enabled: !!roomName && !!meetingId, + }, + ); +} + +export function useRoomJoinMeeting() { + const { setError } = useError(); + + return $api.useMutation( + "post", + "/v1/rooms/{room_name}/meetings/{meeting_id}/join", + { + onError: (error) => { + setError(error as Error, "There was an error joining the meeting"); + }, + }, + ); +} + +export function useRoomIcsSync() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/rooms/{room_name}/ics/sync", { + onError: (error) => { + setError(error as Error, "There was an error syncing the calendar"); + }, + }); +} + +export function useRoomIcsStatus(roomName: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/ics/status", + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName && isAuthenticated, + }, + ); +} + +export function useRoomCalendarEvents(roomName: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms/{room_name}/meetings", + { + params: { + path: { room_name: roomName! }, + }, + }, + { + enabled: !!roomName && isAuthenticated, + }, + ); +} +// End of Calendar integration hooks diff --git a/www/app/lib/array.ts b/www/app/lib/array.ts new file mode 100644 index 00000000..f47aaa42 --- /dev/null +++ b/www/app/lib/array.ts @@ -0,0 +1,12 @@ +export type NonEmptyArray = [T, ...T[]]; +export const isNonEmptyArray = (arr: T[]): arr is NonEmptyArray => + arr.length > 0; +export const assertNonEmptyArray = ( + arr: T[], + err?: string, +): NonEmptyArray => { + if (isNonEmptyArray(arr)) { + return arr; + } + throw new Error(err ?? "Expected non-empty array"); +}; diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts index 9169c694..e562eaed 100644 --- a/www/app/lib/auth.ts +++ b/www/app/lib/auth.ts @@ -1,157 +1,20 @@ -// import { kv } from "@vercel/kv"; -import Redlock, { ResourceLockedError } from "redlock"; -import { AuthOptions } from "next-auth"; -import AuthentikProvider from "next-auth/providers/authentik"; -import { JWT } from "next-auth/jwt"; -import { JWTWithAccessToken, CustomSession } from "./types"; -import Redis from "ioredis"; +import { assertExistsAndNonEmptyString } from "./utils"; -const PRETIMEOUT = 60; // seconds before token expires to refresh it -const DEFAULT_REDIS_KEY_TIMEOUT = 60 * 60 * 24 * 30; // 30 days (refresh token expires in 30 days) -const kv = new Redis(process.env.KV_URL || "", { - tls: {}, -}); -const redlock = new Redlock([kv], {}); +export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const; +// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min +export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000; -redlock.on("error", (error) => { - if (error instanceof ResourceLockedError) { - return; - } - - // Log all other errors. - console.error(error); -}); - -export const authOptions: AuthOptions = { - providers: [ - AuthentikProvider({ - clientId: process.env.AUTHENTIK_CLIENT_ID as string, - clientSecret: process.env.AUTHENTIK_CLIENT_SECRET as string, - issuer: process.env.AUTHENTIK_ISSUER, - authorization: { - params: { - scope: "openid email profile offline_access", - }, - }, - }), - ], - session: { - strategy: "jwt", - }, - callbacks: { - async jwt({ token, account, user }) { - const extendedToken = token as JWTWithAccessToken; - if (account && user) { - // called only on first login - // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is - const expiresAt = (account.expires_at as number) - PRETIMEOUT; - const jwtToken = { - ...extendedToken, - accessToken: account.access_token, - accessTokenExpires: expiresAt * 1000, - refreshToken: account.refresh_token, - }; - kv.set( - `token:${jwtToken.sub}`, - JSON.stringify(jwtToken), - "EX", - DEFAULT_REDIS_KEY_TIMEOUT, - ); - return jwtToken; - } - - if (Date.now() < extendedToken.accessTokenExpires) { - return token; - } - - // access token has expired, try to update it - return await redisLockedrefreshAccessToken(token); - }, - async session({ session, token }) { - const extendedToken = token as JWTWithAccessToken; - const customSession = session as CustomSession; - customSession.accessToken = extendedToken.accessToken; - customSession.accessTokenExpires = extendedToken.accessTokenExpires; - customSession.error = extendedToken.error; - customSession.user = { - id: extendedToken.sub, - name: extendedToken.name, - email: extendedToken.email, - }; - return customSession; - }, - }, +export const shouldRefreshToken = (accessTokenExpires: number): boolean => { + const timeLeft = accessTokenExpires - Date.now(); + return timeLeft < REFRESH_ACCESS_TOKEN_BEFORE; }; -async function redisLockedrefreshAccessToken(token: JWT) { - return await redlock.using( - [token.sub as string, "jwt-refresh"], - 5000, - async () => { - const redisToken = await kv.get(`token:${token.sub}`); - const currentToken = JSON.parse( - redisToken as string, - ) as JWTWithAccessToken; +export const LOGIN_REQUIRED_PAGES = [ + "/transcripts/[!new]", + "/browse(.*)", + "/rooms(.*)", +]; - // if there is multiple requests for the same token, it may already have been refreshed - if (Date.now() < currentToken.accessTokenExpires) { - return currentToken; - } - - // now really do the request - const newToken = await refreshAccessToken(currentToken); - await kv.set( - `token:${currentToken.sub}`, - JSON.stringify(newToken), - "EX", - DEFAULT_REDIS_KEY_TIMEOUT, - ); - return newToken; - }, - ); -} - -async function refreshAccessToken(token: JWT): Promise { - try { - const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`; - - const options = { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: process.env.AUTHENTIK_CLIENT_ID as string, - client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string, - grant_type: "refresh_token", - refresh_token: token.refreshToken as string, - }).toString(), - method: "POST", - }; - - const response = await fetch(url, options); - if (!response.ok) { - console.error( - new Date().toISOString(), - "Failed to refresh access token. Response status:", - response.status, - ); - const responseBody = await response.text(); - console.error(new Date().toISOString(), "Response body:", responseBody); - throw new Error(`Failed to refresh access token: ${response.statusText}`); - } - const refreshedTokens = await response.json(); - return { - ...token, - accessToken: refreshedTokens.access_token, - accessTokenExpires: - Date.now() + (refreshedTokens.expires_in - PRETIMEOUT) * 1000, - refreshToken: refreshedTokens.refresh_token, - }; - } catch (error) { - console.error("Error refreshing access token", error); - return { - ...token, - error: "RefreshAccessTokenError", - } as JWTWithAccessToken; - } -} +export const PROTECTED_PAGES = new RegExp( + LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"), +); diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts new file mode 100644 index 00000000..a44f1d36 --- /dev/null +++ b/www/app/lib/authBackend.ts @@ -0,0 +1,245 @@ +import { AuthOptions } from "next-auth"; +import AuthentikProvider from "next-auth/providers/authentik"; +import type { JWT } from "next-auth/jwt"; +import { JWTWithAccessToken, CustomSession } from "./types"; +import { + assertExists, + assertExistsAndNonEmptyString, + assertNotExists, +} from "./utils"; +import { + REFRESH_ACCESS_TOKEN_BEFORE, + REFRESH_ACCESS_TOKEN_ERROR, + shouldRefreshToken, +} from "./auth"; +import { + getTokenCache, + setTokenCache, + deleteTokenCache, +} from "./redisTokenCache"; +import { tokenCacheRedis, redlock } from "./redisClient"; +import { sequenceThrows } from "./errorUtils"; +import { featureEnabled } from "./features"; +import { getNextEnvVar } from "./nextBuild"; + +const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; +const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID"); +const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET"); +const getAuthentikRefreshTokenUrl = () => + getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL"); + +const getAuthentikIssuer = () => { + const stringUrl = getNextEnvVar("AUTHENTIK_ISSUER"); + try { + new URL(stringUrl); + } catch (e) { + throw new Error("AUTHENTIK_ISSUER is not a valid URL: " + stringUrl); + } + return stringUrl; +}; + +export const authOptions = (): AuthOptions => + featureEnabled("requireLogin") + ? { + providers: [ + AuthentikProvider({ + ...(() => { + const [clientId, clientSecret, issuer] = sequenceThrows( + getAuthentikClientId, + getAuthentikClientSecret, + getAuthentikIssuer, + ); + return { + clientId, + clientSecret, + issuer, + }; + })(), + authorization: { + params: { + scope: "openid email profile offline_access", + }, + }, + }), + ], + session: { + strategy: "jwt", + }, + callbacks: { + async jwt({ token, account, user }) { + if (account && !account.access_token) { + await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); + } + + if (account && user) { + // called only on first login + // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is + if (account.access_token) { + const expiresAtS = assertExists(account.expires_at); + const expiresAtMs = expiresAtS * 1000; + const jwtToken: JWTWithAccessToken = { + ...token, + accessToken: account.access_token, + accessTokenExpires: expiresAtMs, + refreshToken: account.refresh_token, + }; + if (jwtToken.error) { + await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); + } else { + assertNotExists( + jwtToken.error, + `panic! trying to cache token with error in jwt: ${jwtToken.error}`, + ); + await setTokenCache(tokenCacheRedis, `token:${token.sub}`, { + token: jwtToken, + timestamp: Date.now(), + }); + return jwtToken; + } + } + } + + const currentToken = await getTokenCache( + tokenCacheRedis, + `token:${token.sub}`, + ); + console.debug( + "currentToken from cache", + JSON.stringify(currentToken, null, 2), + "will be returned?", + currentToken && + !shouldRefreshToken(currentToken.token.accessTokenExpires), + ); + if ( + currentToken && + !shouldRefreshToken(currentToken.token.accessTokenExpires) + ) { + return currentToken.token; + } + + // access token has expired, try to update it + return await lockedRefreshAccessToken(token); + }, + async session({ session, token }) { + const extendedToken = token as JWTWithAccessToken; + return { + ...session, + accessToken: extendedToken.accessToken, + accessTokenExpires: extendedToken.accessTokenExpires, + error: extendedToken.error, + user: { + id: assertExists(extendedToken.sub), + name: extendedToken.name, + email: extendedToken.email, + }, + } satisfies CustomSession; + }, + }, + } + : { + providers: [], + }; + +async function lockedRefreshAccessToken( + token: JWT, +): Promise { + const lockKey = `${token.sub}-lock`; + + return redlock + .using([lockKey], 10000, async () => { + const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`); + if (cached) + console.debug( + "received cached token. to delete?", + Date.now() - cached.timestamp > TOKEN_CACHE_TTL, + ); + else console.debug("no cached token received"); + if (cached) { + if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) { + await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); + } else if (!shouldRefreshToken(cached.token.accessTokenExpires)) { + console.debug("returning cached token", cached.token); + return cached.token; + } + } + + const currentToken = cached?.token || (token as JWTWithAccessToken); + const newToken = await refreshAccessToken(currentToken); + + console.debug("current token during refresh", currentToken); + console.debug("new token during refresh", newToken); + + if (newToken.error) { + await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); + return newToken; + } + + assertNotExists( + newToken.error, + `panic! trying to cache token with error during refresh: ${newToken.error}`, + ); + await setTokenCache(tokenCacheRedis, `token:${token.sub}`, { + token: newToken, + timestamp: Date.now(), + }); + + return newToken; + }) + .catch((e) => { + console.error("error refreshing token", e); + deleteTokenCache(tokenCacheRedis, `token:${token.sub}`).catch((e) => { + console.error("error deleting errored token", e); + }); + return { + ...token, + error: REFRESH_ACCESS_TOKEN_ERROR, + } as JWTWithAccessToken; + }); +} + +async function refreshAccessToken(token: JWT): Promise { + const [url, clientId, clientSecret] = sequenceThrows( + getAuthentikRefreshTokenUrl, + getAuthentikClientId, + getAuthentikClientSecret, + ); + try { + const options = { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: token.refreshToken as string, + }).toString(), + method: "POST", + }; + + const response = await fetch(url, options); + if (!response.ok) { + console.error( + new Date().toISOString(), + "Failed to refresh access token. Response status:", + response.status, + ); + const responseBody = await response.text(); + console.error(new Date().toISOString(), "Response body:", responseBody); + throw new Error(`Failed to refresh access token: ${response.statusText}`); + } + const refreshedTokens = await response.json(); + return { + ...token, + accessToken: refreshedTokens.access_token, + accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, + refreshToken: refreshedTokens.refresh_token, + }; + } catch (error) { + console.error("Error refreshing access token", error); + return { + ...token, + error: REFRESH_ACCESS_TOKEN_ERROR, + } as JWTWithAccessToken; + } +} diff --git a/www/app/lib/clientEnv.ts b/www/app/lib/clientEnv.ts new file mode 100644 index 00000000..2c4a01c8 --- /dev/null +++ b/www/app/lib/clientEnv.ts @@ -0,0 +1,91 @@ +import { + assertExists, + assertExistsAndNonEmptyString, + NonEmptyString, + parseNonEmptyString, +} from "./utils"; +import { isBuildPhase } from "./next"; +import { getNextEnvVar } from "./nextBuild"; + +export const FEATURE_REQUIRE_LOGIN_ENV_NAME = "FEATURE_REQUIRE_LOGIN" as const; +export const FEATURE_PRIVACY_ENV_NAME = "FEATURE_PRIVACY" as const; +export const FEATURE_BROWSE_ENV_NAME = "FEATURE_BROWSE" as const; +export const FEATURE_SEND_TO_ZULIP_ENV_NAME = "FEATURE_SEND_TO_ZULIP" as const; +export const FEATURE_ROOMS_ENV_NAME = "FEATURE_ROOMS" as const; + +const FEATURE_ENV_NAMES = [ + FEATURE_REQUIRE_LOGIN_ENV_NAME, + FEATURE_PRIVACY_ENV_NAME, + FEATURE_BROWSE_ENV_NAME, + FEATURE_SEND_TO_ZULIP_ENV_NAME, + FEATURE_ROOMS_ENV_NAME, +] as const; + +export type FeatureEnvName = (typeof FEATURE_ENV_NAMES)[number]; + +export type EnvFeaturePartial = { + [key in FeatureEnvName]: boolean | null; +}; + +// CONTRACT: isomorphic with JSON.stringify +export type ClientEnvCommon = EnvFeaturePartial & { + API_URL: NonEmptyString; + WEBSOCKET_URL: NonEmptyString | null; +}; + +let clientEnv: ClientEnvCommon | null = null; +export const getClientEnvClient = (): ClientEnvCommon => { + if (typeof window === "undefined") { + throw new Error( + "getClientEnv() called during SSR - this should only be called in browser environment", + ); + } + if (clientEnv) return clientEnv; + clientEnv = assertExists( + JSON.parse( + assertExistsAndNonEmptyString( + document.body.dataset.env, + "document.body.dataset.env is missing", + ), + ), + "document.body.dataset.env is parsed to nullish", + ); + return clientEnv!; +}; + +const parseBooleanString = (str: string | undefined): boolean | null => { + if (str === undefined) return null; + return str === "true"; +}; + +export const getClientEnvServer = (): ClientEnvCommon => { + if (typeof window !== "undefined") { + throw new Error( + "getClientEnv() not called during SSR - this should only be called in server environment", + ); + } + if (clientEnv) return clientEnv; + + const features = FEATURE_ENV_NAMES.reduce((acc, x) => { + acc[x] = parseBooleanString(process.env[x]); + return acc; + }, {} as EnvFeaturePartial); + + if (isBuildPhase) { + return { + API_URL: getNextEnvVar("API_URL"), + WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"), + ...features, + }; + } + + clientEnv = { + API_URL: getNextEnvVar("API_URL"), + WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"), + ...features, + }; + return clientEnv; +}; + +export const getClientEnv = + typeof window === "undefined" ? getClientEnvServer : getClientEnvClient; diff --git a/www/app/lib/consent/ConsentDialog.tsx b/www/app/lib/consent/ConsentDialog.tsx new file mode 100644 index 00000000..488599d0 --- /dev/null +++ b/www/app/lib/consent/ConsentDialog.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react"; +import { CONSENT_DIALOG_TEXT } from "./constants"; + +interface ConsentDialogProps { + onAccept: () => void; + onReject: () => void; +} + +export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) { + return ( + + + + {CONSENT_DIALOG_TEXT.question} + + + + + + + + ); +} diff --git a/www/app/lib/consent/ConsentDialogButton.tsx b/www/app/lib/consent/ConsentDialogButton.tsx new file mode 100644 index 00000000..2c1d084b --- /dev/null +++ b/www/app/lib/consent/ConsentDialogButton.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Button, Icon } from "@chakra-ui/react"; +import { FaBars } from "react-icons/fa6"; +import { useConsentDialog } from "./useConsentDialog"; +import { + CONSENT_BUTTON_TOP_OFFSET, + CONSENT_BUTTON_LEFT_OFFSET, + CONSENT_BUTTON_Z_INDEX, + CONSENT_DIALOG_TEXT, +} from "./constants"; + +interface ConsentDialogButtonProps { + meetingId: string; +} + +export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) { + const { showConsentModal, consentState, hasConsent, consentLoading } = + useConsentDialog(meetingId); + + if (!consentState.ready || hasConsent(meetingId) || consentLoading) { + return null; + } + + return ( + + ); +} diff --git a/www/app/lib/consent/constants.ts b/www/app/lib/consent/constants.ts new file mode 100644 index 00000000..41e7c7e1 --- /dev/null +++ b/www/app/lib/consent/constants.ts @@ -0,0 +1,12 @@ +export const CONSENT_BUTTON_TOP_OFFSET = "56px"; +export const CONSENT_BUTTON_LEFT_OFFSET = "8px"; +export const CONSENT_BUTTON_Z_INDEX = 1000; +export const TOAST_CHECK_INTERVAL_MS = 100; + +export const CONSENT_DIALOG_TEXT = { + question: + "Can we have your permission to store this meeting's audio recording on our servers?", + acceptButton: "Yes, store the audio", + rejectButton: "No, delete after transcription", + triggerButton: "Meeting is being recorded", +} as const; diff --git a/www/app/lib/consent/index.ts b/www/app/lib/consent/index.ts new file mode 100644 index 00000000..eabca8ac --- /dev/null +++ b/www/app/lib/consent/index.ts @@ -0,0 +1,8 @@ +"use client"; + +export { ConsentDialogButton } from "./ConsentDialogButton"; +export { ConsentDialog } from "./ConsentDialog"; +export { useConsentDialog } from "./useConsentDialog"; +export { recordingTypeRequiresConsent } from "./utils"; +export * from "./constants"; +export * from "./types"; diff --git a/www/app/lib/consent/types.ts b/www/app/lib/consent/types.ts new file mode 100644 index 00000000..0bd15202 --- /dev/null +++ b/www/app/lib/consent/types.ts @@ -0,0 +1,9 @@ +export interface ConsentDialogResult { + showConsentModal: () => void; + consentState: { + ready: boolean; + consentAnsweredForMeetings?: Set; + }; + hasConsent: (meetingId: string) => boolean; + consentLoading: boolean; +} diff --git a/www/app/lib/consent/useConsentDialog.tsx b/www/app/lib/consent/useConsentDialog.tsx new file mode 100644 index 00000000..2a5c0ab3 --- /dev/null +++ b/www/app/lib/consent/useConsentDialog.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useCallback, useState, useEffect, useRef } from "react"; +import { toaster } from "../../components/ui/toaster"; +import { useRecordingConsent } from "../../recordingConsentContext"; +import { useMeetingAudioConsent } from "../apiHooks"; +import { ConsentDialog } from "./ConsentDialog"; +import { TOAST_CHECK_INTERVAL_MS } from "./constants"; +import type { ConsentDialogResult } from "./types"; + +export function useConsentDialog(meetingId: string): ConsentDialogResult { + const { state: consentState, touch, hasConsent } = useRecordingConsent(); + const [modalOpen, setModalOpen] = useState(false); + const audioConsentMutation = useMeetingAudioConsent(); + const intervalRef = useRef(null); + const keydownHandlerRef = useRef<((event: KeyboardEvent) => void) | null>( + null, + ); + + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (keydownHandlerRef.current) { + document.removeEventListener("keydown", keydownHandlerRef.current); + keydownHandlerRef.current = null; + } + }; + }, []); + + const handleConsent = useCallback( + async (given: boolean) => { + try { + await audioConsentMutation.mutateAsync({ + params: { + path: { meeting_id: meetingId }, + }, + body: { + consent_given: given, + }, + }); + + touch(meetingId); + } catch (error) { + console.error("Error submitting consent:", error); + } + }, + [audioConsentMutation, touch, meetingId], + ); + + const showConsentModal = useCallback(() => { + if (modalOpen) return; + + setModalOpen(true); + + const toastId = toaster.create({ + placement: "top", + duration: null, + render: ({ dismiss }) => ( + { + handleConsent(true); + dismiss(); + }} + onReject={() => { + handleConsent(false); + dismiss(); + }} + /> + ), + }); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + toastId.then((id) => toaster.dismiss(id)); + } + }; + + keydownHandlerRef.current = handleKeyDown; + document.addEventListener("keydown", handleKeyDown); + + toastId.then((id) => { + intervalRef.current = setInterval(() => { + if (!toaster.isActive(id)) { + setModalOpen(false); + + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + if (keydownHandlerRef.current) { + document.removeEventListener("keydown", keydownHandlerRef.current); + keydownHandlerRef.current = null; + } + } + }, TOAST_CHECK_INTERVAL_MS); + }); + }, [handleConsent, modalOpen]); + + return { + showConsentModal, + consentState, + hasConsent, + consentLoading: audioConsentMutation.isPending, + }; +} diff --git a/www/app/lib/consent/utils.ts b/www/app/lib/consent/utils.ts new file mode 100644 index 00000000..146bdd68 --- /dev/null +++ b/www/app/lib/consent/utils.ts @@ -0,0 +1,13 @@ +import type { components } from "../../reflector-api"; + +type Meeting = components["schemas"]["Meeting"]; + +/** + * Determines if a meeting's recording type requires user consent. + * Currently only "cloud" recordings require consent. + */ +export function recordingTypeRequiresConsent( + recordingType: Meeting["recording_type"], +): boolean { + return recordingType === "cloud"; +} diff --git a/www/app/lib/edgeConfig.ts b/www/app/lib/edgeConfig.ts deleted file mode 100644 index 2e31e146..00000000 --- a/www/app/lib/edgeConfig.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { get } from "@vercel/edge-config"; -import { isDevelopment } from "./utils"; - -type EdgeConfig = { - [domainWithDash: string]: { - features: { - [featureName in - | "requireLogin" - | "privacy" - | "browse" - | "sendToZulip"]: boolean; - }; - auth_callback_url: string; - websocket_url: string; - api_url: string; - }; -}; - -export type DomainConfig = EdgeConfig["domainWithDash"]; - -// Edge config main keys can only be alphanumeric and _ or - -export function edgeKeyToDomain(key: string) { - return key.replaceAll("_", "."); -} - -export function edgeDomainToKey(domain: string) { - return domain.replaceAll(".", "_"); -} - -// get edge config server-side (prefer DomainContext when available), domain is the hostname -export async function getConfig() { - const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname; - - if (process.env.NEXT_PUBLIC_ENV === "development") { - return require("../../config").localConfig; - } - - let config = await get(edgeDomainToKey(domain)); - - if (typeof config !== "object") { - console.warn("No config for this domain, falling back to default"); - config = await get(edgeDomainToKey("default")); - } - - if (typeof config !== "object") throw Error("Error fetching config"); - - return config as DomainConfig; -} diff --git a/www/app/lib/errorUtils.ts b/www/app/lib/errorUtils.ts index e9e5300d..1512230c 100644 --- a/www/app/lib/errorUtils.ts +++ b/www/app/lib/errorUtils.ts @@ -1,4 +1,6 @@ -function shouldShowError(error: Error | null | undefined) { +import { isNonEmptyArray, NonEmptyArray } from "./array"; + +export function shouldShowError(error: Error | null | undefined) { if ( error?.name == "ResponseError" && (error["response"].status == 404 || error["response"].status == 403) @@ -8,4 +10,40 @@ function shouldShowError(error: Error | null | undefined) { return true; } -export { shouldShowError }; +const defaultMergeErrors = (ex: NonEmptyArray): unknown => { + try { + return new Error( + ex + .map((e) => + e ? (e.toString ? e.toString() : JSON.stringify(e)) : `${e}`, + ) + .join("\n"), + ); + } catch (e) { + console.error("Error merging errors:", e); + return ex[0]; + } +}; + +type ReturnTypes any)[]> = { + [K in keyof T]: T[K] extends () => infer R ? R : never; +}; + +// sequence semantic for "throws" +// calls functions passed and collects its thrown values +export function sequenceThrows any)[]>( + ...fs: Fns +): ReturnTypes { + const results: unknown[] = []; + const errors: unknown[] = []; + + for (const f of fs) { + try { + results.push(f()); + } catch (e) { + errors.push(e); + } + } + if (errors.length) throw defaultMergeErrors(errors as NonEmptyArray); + return results as ReturnTypes; +} diff --git a/www/app/lib/features.ts b/www/app/lib/features.ts new file mode 100644 index 00000000..eebfc816 --- /dev/null +++ b/www/app/lib/features.ts @@ -0,0 +1,57 @@ +import { + FEATURE_BROWSE_ENV_NAME, + FEATURE_PRIVACY_ENV_NAME, + FEATURE_REQUIRE_LOGIN_ENV_NAME, + FEATURE_ROOMS_ENV_NAME, + FEATURE_SEND_TO_ZULIP_ENV_NAME, + FeatureEnvName, + getClientEnv, +} from "./clientEnv"; + +export const FEATURES = [ + "requireLogin", + "privacy", + "browse", + "sendToZulip", + "rooms", +] as const; + +export type FeatureName = (typeof FEATURES)[number]; + +export type Features = Readonly>; + +export const DEFAULT_FEATURES: Features = { + requireLogin: true, + privacy: true, + browse: true, + sendToZulip: true, + rooms: true, +} as const; + +export const ENV_TO_FEATURE: { + [k in FeatureEnvName]: FeatureName; +} = { + FEATURE_REQUIRE_LOGIN: "requireLogin", + FEATURE_PRIVACY: "privacy", + FEATURE_BROWSE: "browse", + FEATURE_SEND_TO_ZULIP: "sendToZulip", + FEATURE_ROOMS: "rooms", +} as const; + +export const FEATURE_TO_ENV: { + [k in FeatureName]: FeatureEnvName; +} = { + requireLogin: "FEATURE_REQUIRE_LOGIN", + privacy: "FEATURE_PRIVACY", + browse: "FEATURE_BROWSE", + sendToZulip: "FEATURE_SEND_TO_ZULIP", + rooms: "FEATURE_ROOMS", +}; + +const features = getClientEnv(); + +export const featureEnabled = (featureName: FeatureName): boolean => { + const isSet = features[FEATURE_TO_ENV[featureName]]; + if (isSet === null) return DEFAULT_FEATURES[featureName]; + return isSet; +}; diff --git a/www/app/lib/next.ts b/www/app/lib/next.ts new file mode 100644 index 00000000..91d88bd2 --- /dev/null +++ b/www/app/lib/next.ts @@ -0,0 +1,2 @@ +// next.js tries to run all the lib code during build phase; we don't always want it when e.g. we have connections initialized we don't want to have +export const isBuildPhase = process.env.NEXT_PHASE?.includes("build"); diff --git a/www/app/lib/nextBuild.ts b/www/app/lib/nextBuild.ts new file mode 100644 index 00000000..b2e13797 --- /dev/null +++ b/www/app/lib/nextBuild.ts @@ -0,0 +1,17 @@ +import { isBuildPhase } from "./next"; +import { assertExistsAndNonEmptyString, NonEmptyString } from "./utils"; + +const _getNextEnvVar = (name: string, e?: string): NonEmptyString => + isBuildPhase + ? (() => { + throw new Error( + "panic! getNextEnvVar called during build phase; we don't support build envs", + ); + })() + : assertExistsAndNonEmptyString( + process.env[name], + `${name} is required; ${e}`, + ); + +export const getNextEnvVar = (name: string, e?: string): NonEmptyString => + _getNextEnvVar(name, e); diff --git a/www/app/lib/queryClient.tsx b/www/app/lib/queryClient.tsx new file mode 100644 index 00000000..bd5946e0 --- /dev/null +++ b/www/app/lib/queryClient.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime) + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 0, + }, + }, +}); diff --git a/www/app/lib/redisClient.ts b/www/app/lib/redisClient.ts new file mode 100644 index 00000000..aeb3595b --- /dev/null +++ b/www/app/lib/redisClient.ts @@ -0,0 +1,78 @@ +import Redis from "ioredis"; +import { isBuildPhase } from "./next"; +import Redlock, { ResourceLockedError } from "redlock"; + +export type RedisClient = Pick; +export type RedlockClient = { + using: ( + keys: string | string[], + ttl: number, + cb: () => Promise, + ) => Promise; +}; +const KV_USE_TLS = process.env.KV_USE_TLS + ? process.env.KV_USE_TLS === "true" + : undefined; + +let redisClient: Redis | null = null; + +const getRedisClient = (): RedisClient => { + if (redisClient) return redisClient; + const redisUrl = process.env.KV_URL; + if (!redisUrl) { + throw new Error("KV_URL environment variable is required"); + } + redisClient = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + ...(KV_USE_TLS === true + ? { + tls: {}, + } + : {}), + }); + + redisClient.on("error", (error) => { + console.error("Redis error:", error); + }); + + return redisClient; +}; + +// next.js buildtime usage - we want to isolate next.js "build" time concepts here +const noopClient: RedisClient = (() => { + const noopSetex: Redis["setex"] = async () => { + return "OK" as const; + }; + const noopDel: Redis["del"] = async () => { + return 0; + }; + return { + get: async () => { + return null; + }, + setex: noopSetex, + del: noopDel, + }; +})(); + +const noopRedlock: RedlockClient = { + using: (resource: string | string[], ttl: number, cb: () => Promise) => + cb(), +}; + +export const redlock: RedlockClient = isBuildPhase + ? noopRedlock + : (() => { + const r = new Redlock([getRedisClient()], {}); + r.on("error", (error) => { + if (error instanceof ResourceLockedError) { + return; + } + + // Log all other errors. + console.error(error); + }); + return r; + })(); + +export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient(); diff --git a/www/app/lib/redisTokenCache.ts b/www/app/lib/redisTokenCache.ts new file mode 100644 index 00000000..a8b720ef --- /dev/null +++ b/www/app/lib/redisTokenCache.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth"; + +const TokenCacheEntrySchema = z.object({ + token: z.object({ + sub: z.string().optional(), + name: z.string().nullish(), + email: z.string().nullish(), + accessToken: z.string(), + accessTokenExpires: z.number(), + refreshToken: z.string().optional(), + }), + timestamp: z.number(), +}); + +const TokenCacheEntryCodec = z.codec(z.string(), TokenCacheEntrySchema, { + decode: (jsonString) => { + const parsed = JSON.parse(jsonString); + return TokenCacheEntrySchema.parse(parsed); + }, + encode: (value) => JSON.stringify(value), +}); + +export type TokenCacheEntry = z.infer; + +export type KV = { + get(key: string): Promise; + setex(key: string, seconds: number, value: string): Promise<"OK">; + del(key: string): Promise; +}; + +export async function getTokenCache( + redis: KV, + key: string, +): Promise { + const data = await redis.get(key); + if (!data) return null; + + try { + return TokenCacheEntryCodec.decode(data); + } catch (error) { + console.error("Invalid token cache data:", error); + await redis.del(key); + return null; + } +} + +const TTL_SECONDS = 30 * 24 * 60 * 60; + +export async function setTokenCache( + redis: KV, + key: string, + value: TokenCacheEntry, +): Promise { + const encodedValue = TokenCacheEntryCodec.encode(value); + await redis.setex(key, TTL_SECONDS, encodedValue); +} + +export async function deleteTokenCache(redis: KV, key: string): Promise { + await redis.del(key); +} diff --git a/www/app/lib/routes.ts b/www/app/lib/routes.ts new file mode 100644 index 00000000..480082d0 --- /dev/null +++ b/www/app/lib/routes.ts @@ -0,0 +1,7 @@ +import { NonEmptyString } from "./utils"; + +export const roomUrl = (roomName: NonEmptyString) => `/${roomName}`; +export const roomMeetingUrl = ( + roomName: NonEmptyString, + meetingId: NonEmptyString, +) => `${roomUrl(roomName)}/${meetingId}`; diff --git a/www/app/lib/routesClient.ts b/www/app/lib/routesClient.ts new file mode 100644 index 00000000..9522bc74 --- /dev/null +++ b/www/app/lib/routesClient.ts @@ -0,0 +1,5 @@ +import { roomUrl } from "./routes"; +import { NonEmptyString } from "./utils"; + +export const roomAbsoluteUrl = (roomName: NonEmptyString) => + `${window.location.origin}${roomUrl(roomName)}`; diff --git a/www/app/lib/timeUtils.ts b/www/app/lib/timeUtils.ts new file mode 100644 index 00000000..db8a8152 --- /dev/null +++ b/www/app/lib/timeUtils.ts @@ -0,0 +1,25 @@ +export const formatDateTime = (d: Date): string => { + return d.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export const formatStartedAgo = ( + startTime: Date, + now: Date = new Date(), +): string => { + const diff = now.getTime() - startTime.getTime(); + + if (diff <= 0) return "Starting now"; + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `Started ${days}d ${hours % 24}h ${minutes % 60}m ago`; + if (hours > 0) return `Started ${hours}h ${minutes % 60}m ago`; + return `Started ${minutes} minutes ago`; +}; diff --git a/www/app/lib/transcript.ts b/www/app/lib/transcript.ts new file mode 100644 index 00000000..d1fd8b3d --- /dev/null +++ b/www/app/lib/transcript.ts @@ -0,0 +1,5 @@ +import { components } from "../reflector-api"; + +type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"]; + +export type TranscriptStatus = ApiTranscriptStatus; diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts index 851ee5be..7bcb522b 100644 --- a/www/app/lib/types.ts +++ b/www/app/lib/types.ts @@ -1,10 +1,11 @@ -import { Session } from "next-auth"; -import { JWT } from "next-auth/jwt"; +import type { Session } from "next-auth"; +import type { JWT } from "next-auth/jwt"; +import { parseMaybeNonEmptyString } from "./utils"; export interface JWTWithAccessToken extends JWT { accessToken: string; accessTokenExpires: number; - refreshToken: string; + refreshToken?: string; error?: string; } @@ -12,9 +13,68 @@ export interface CustomSession extends Session { accessToken: string; accessTokenExpires: number; error?: string; - user: { - id?: string; - name?: string | null; - email?: string | null; + user: Session["user"] & { + id: string; }; } + +// assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there +// but the assumption is crucial to auth working +export const assertExtendedToken = ( + t: Exclude, +): T & { + accessTokenExpires: number; + accessToken: string; +} => { + if ( + typeof (t as { accessTokenExpires: any }).accessTokenExpires === "number" && + !isNaN((t as { accessTokenExpires: any }).accessTokenExpires) && + typeof ( + t as { + accessToken: any; + } + ).accessToken === "string" && + parseMaybeNonEmptyString((t as { accessToken: any }).accessToken) !== null + ) { + return t as T & { + accessTokenExpires: number; + accessToken: string; + }; + } + throw new Error("Token is not extended with access token"); +}; + +export const assertExtendedTokenAndUserId = ( + t: Exclude, +): T & { + accessTokenExpires: number; + accessToken: string; + user: U & { + id: string; + }; +} => { + const extendedToken = assertExtendedToken(t); + if (typeof (extendedToken.user as any)?.id === "string") { + return t as Exclude & { + accessTokenExpires: number; + accessToken: string; + user: U & { + id: string; + }; + }; + } + throw new Error("Token is not extended with user id"); +}; + +// best attempt to check the session is valid +export const assertCustomSession = ( + s: Exclude, +): CustomSession => { + const r = assertExtendedTokenAndUserId(s); + // no other checks for now + return r as CustomSession; +}; + +export type Mutable = { + -readonly [P in keyof T]: T[P]; +}; diff --git a/www/app/lib/useApi.ts b/www/app/lib/useApi.ts deleted file mode 100644 index 837ef84f..00000000 --- a/www/app/lib/useApi.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useSession, signOut } from "next-auth/react"; -import { useContext, useEffect, useState } from "react"; -import { DomainContext, featureEnabled } from "../domainContext"; -import { OpenApi, DefaultService } from "../api"; -import { CustomSession } from "./types"; -import useSessionStatus from "./useSessionStatus"; -import useSessionAccessToken from "./useSessionAccessToken"; - -export default function useApi(): DefaultService | null { - const api_url = useContext(DomainContext).api_url; - const [api, setApi] = useState(null); - const { isLoading, isAuthenticated } = useSessionStatus(); - const { accessToken, error } = useSessionAccessToken(); - - if (!api_url) throw new Error("no API URL"); - - useEffect(() => { - if (error === "RefreshAccessTokenError") { - signOut(); - } - }, [error]); - - useEffect(() => { - if (isLoading || (isAuthenticated && !accessToken)) { - return; - } - - const openApi = new OpenApi({ - BASE: api_url, - TOKEN: accessToken || undefined, - }); - - setApi(openApi); - }, [isLoading, isAuthenticated, accessToken]); - - return api?.default ?? null; -} diff --git a/www/app/lib/useLoginRequiredPages.ts b/www/app/lib/useLoginRequiredPages.ts new file mode 100644 index 00000000..d0dee1b6 --- /dev/null +++ b/www/app/lib/useLoginRequiredPages.ts @@ -0,0 +1,29 @@ +// for paths that are not supposed to be public +import { PROTECTED_PAGES } from "./auth"; +import { usePathname } from "next/navigation"; +import { useAuth } from "./AuthProvider"; +import { useEffect } from "react"; +import { featureEnabled } from "./features"; + +const HOME = "/" as const; + +export const useLoginRequiredPages = () => { + const pathname = usePathname(); + const isProtected = PROTECTED_PAGES.test(pathname); + const auth = useAuth(); + const isNotLoggedIn = auth.status === "unauthenticated"; + // safety + const isLastDestination = pathname === HOME; + const requireLogin = featureEnabled("requireLogin"); + const shouldRedirect = + requireLogin && isNotLoggedIn && isProtected && !isLastDestination; + useEffect(() => { + if (!shouldRedirect) return; + // on the backend, the redirect goes straight to the auth provider, but we don't have it because it's hidden inside next-auth middleware + // so we just "softly" lead the user to the main page + // warning: if HOME redirects somewhere else, we won't be protected by isLastDestination + window.location.href = HOME; + }, [shouldRedirect]); + // optionally save from blink, since window.location.href takes a bit of time + return shouldRedirect ? HOME : null; +}; diff --git a/www/app/lib/useSessionAccessToken.ts b/www/app/lib/useSessionAccessToken.ts deleted file mode 100644 index fc28c076..00000000 --- a/www/app/lib/useSessionAccessToken.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useSession as useNextAuthSession } from "next-auth/react"; -import { CustomSession } from "./types"; - -export default function useSessionAccessToken() { - const { data: session } = useNextAuthSession(); - const customSession = session as CustomSession; - const naAccessToken = customSession?.accessToken; - const naAccessTokenExpires = customSession?.accessTokenExpires; - const naError = customSession?.error; - const [accessToken, setAccessToken] = useState(null); - const [accessTokenExpires, setAccessTokenExpires] = useState( - null, - ); - const [error, setError] = useState(); - - useEffect(() => { - if (naAccessToken !== accessToken) { - setAccessToken(naAccessToken); - } - }, [naAccessToken]); - - useEffect(() => { - if (naAccessTokenExpires !== accessTokenExpires) { - setAccessTokenExpires(naAccessTokenExpires); - } - }, [naAccessTokenExpires]); - - useEffect(() => { - if (naError !== error) { - setError(naError); - } - }, [naError]); - - return { - accessToken, - accessTokenExpires, - error, - }; -} diff --git a/www/app/lib/useSessionStatus.ts b/www/app/lib/useSessionStatus.ts deleted file mode 100644 index 5629c025..00000000 --- a/www/app/lib/useSessionStatus.ts +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useSession as useNextAuthSession } from "next-auth/react"; -import { Session } from "next-auth"; - -export default function useSessionStatus() { - const { status: naStatus } = useNextAuthSession(); - const [status, setStatus] = useState("loading"); - - useEffect(() => { - if (naStatus !== "loading" && naStatus !== status) { - setStatus(naStatus); - } - }, [naStatus]); - - return { - status, - isLoading: status === "loading", - isAuthenticated: status === "authenticated", - }; -} diff --git a/www/app/lib/useSessionUser.ts b/www/app/lib/useSessionUser.ts deleted file mode 100644 index 2da299f5..00000000 --- a/www/app/lib/useSessionUser.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useSession as useNextAuthSession } from "next-auth/react"; -import { Session } from "next-auth"; - -// user type with id, name, email -export interface User { - id?: string | null; - name?: string | null; - email?: string | null; -} - -export default function useSessionUser() { - const { data: session } = useNextAuthSession(); - const [user, setUser] = useState(null); - - useEffect(() => { - if (!session?.user) { - setUser(null); - return; - } - if (JSON.stringify(session.user) !== JSON.stringify(user)) { - setUser(session.user); - } - }, [session]); - - return { - id: user?.id, - name: user?.name, - email: user?.email, - }; -} diff --git a/www/app/lib/useUserName.ts b/www/app/lib/useUserName.ts new file mode 100644 index 00000000..46850176 --- /dev/null +++ b/www/app/lib/useUserName.ts @@ -0,0 +1,8 @@ +import { useAuth } from "./AuthProvider"; + +export const useUserName = (): string | null | undefined => { + const auth = useAuth(); + if (auth.status !== "authenticated" && auth.status !== "refreshing") + return undefined; + return auth.user?.name || null; +}; diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts index 80d0d91b..e9260a9b 100644 --- a/www/app/lib/utils.ts +++ b/www/app/lib/utils.ts @@ -1,7 +1,3 @@ -export function isDevelopment() { - return process.env.NEXT_PUBLIC_ENV === "development"; -} - // Function to calculate WCAG contrast ratio export const getContrastRatio = ( foreground: [number, number, number], @@ -137,9 +133,51 @@ export function extractDomain(url) { } } -export function assertExists(value: T | null | undefined, err?: string): T { +export type NonEmptyString = string & { __brand: "NonEmptyString" }; +export const parseMaybeNonEmptyString = ( + s: string, + trim = true, +): NonEmptyString | null => { + s = trim ? s.trim() : s; + return s.length > 0 ? (s as NonEmptyString) : null; +}; +export const parseNonEmptyString = ( + s: string, + trim = true, + e?: string, +): NonEmptyString => + assertExists( + parseMaybeNonEmptyString(s, trim), + "Expected non-empty string" + (e ? `: ${e}` : ""), + ); + +export const assertExists = ( + value: T | null | undefined, + err?: string, +): T => { if (value === null || value === undefined) { throw new Error(`Assertion failed: ${err ?? "value is null or undefined"}`); } return value; -} +}; + +export const assertNotExists = ( + value: T | null | undefined, + err?: string, +): void => { + if (value !== null && value !== undefined) { + throw new Error( + `Assertion failed: ${err ?? "value is not null or undefined"}`, + ); + } +}; + +export const assertExistsAndNonEmptyString = ( + value: string | null | undefined, + err?: string, +): NonEmptyString => + parseNonEmptyString( + assertExists(value, err || "Expected non-empty string"), + true, + err, + ); diff --git a/www/app/lib/wherebyClient.ts b/www/app/lib/wherebyClient.ts new file mode 100644 index 00000000..2345bd7b --- /dev/null +++ b/www/app/lib/wherebyClient.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import { components } from "../reflector-api"; + +export const useWhereby = () => { + const [wherebyLoaded, setWherebyLoaded] = useState(false); + useEffect(() => { + if (typeof window !== "undefined") { + import("@whereby.com/browser-sdk/embed") + .then(() => { + setWherebyLoaded(true); + }) + .catch(console.error.bind(console)); + } + }, []); + return wherebyLoaded; +}; + +export const getWherebyUrl = ( + meeting: Pick, +) => + // host_room_url possible '' atm + meeting.host_room_url || meeting.room_url; diff --git a/www/app/providers.tsx b/www/app/providers.tsx index f0f1ea52..6e689812 100644 --- a/www/app/providers.tsx +++ b/www/app/providers.tsx @@ -2,20 +2,44 @@ import { ChakraProvider } from "@chakra-ui/react"; import system from "./styles/theme"; +import dynamic from "next/dynamic"; -import { WherebyProvider } from "@whereby.com/browser-sdk/react"; import { Toaster } from "./components/ui/toaster"; import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./lib/queryClient"; +import { AuthProvider } from "./lib/AuthProvider"; +import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; +import { RecordingConsentProvider } from "./recordingConsentContext"; +import { UserEventsProvider } from "./lib/UserEventsProvider"; + +const WherebyProvider = dynamic( + () => + import("@whereby.com/browser-sdk/react").then((mod) => ({ + default: mod.WherebyProvider, + })), + { ssr: false }, +); export function Providers({ children }: { children: React.ReactNode }) { return ( - - - {children} - - - + + + + + + + + {children} + + + + + + + + ); } diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts new file mode 100644 index 00000000..9b9582ba --- /dev/null +++ b/www/app/reflector-api.d.ts @@ -0,0 +1,3285 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health */ + get: operations["health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Metrics + * @description Endpoint that serves Prometheus metrics. + */ + get: operations["metrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/meetings/{meeting_id}/consent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Meeting Audio Consent */ + post: operations["v1_meeting_audio_consent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/meetings/{meeting_id}/deactivate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Meeting Deactivate */ + patch: operations["v1_meeting_deactivate"]; + trace?: never; + }; + "/v1/rooms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List */ + get: operations["v1_rooms_list"]; + put?: never; + /** Rooms Create */ + post: operations["v1_rooms_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Get */ + get: operations["v1_rooms_get"]; + put?: never; + post?: never; + /** Rooms Delete */ + delete: operations["v1_rooms_delete"]; + options?: never; + head?: never; + /** Rooms Update */ + patch: operations["v1_rooms_update"]; + trace?: never; + }; + "/v1/rooms/name/{room_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Get By Name */ + get: operations["v1_rooms_get_by_name"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meeting": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Create Meeting */ + post: operations["v1_rooms_create_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_id}/webhook/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rooms Test Webhook + * @description Test webhook configuration by sending a sample payload. + */ + post: operations["v1_rooms_test_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/ics/sync": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Sync Ics */ + post: operations["v1_rooms_sync_ics"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/ics/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Ics Status */ + get: operations["v1_rooms_ics_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Meetings */ + get: operations["v1_rooms_list_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/upcoming": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Upcoming Meetings */ + get: operations["v1_rooms_list_upcoming_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/active": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List Active Meetings */ + get: operations["v1_rooms_list_active_meetings"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Rooms Get Meeting + * @description Get a single meeting by ID within a specific room. + */ + get: operations["v1_rooms_get_meeting"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_name}/meetings/{meeting_id}/join": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Join Meeting */ + post: operations["v1_rooms_join_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcripts List */ + get: operations["v1_transcripts_list"]; + put?: never; + /** Transcripts Create */ + post: operations["v1_transcripts_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Transcripts Search + * @description Full-text search across transcript titles and content. + */ + get: operations["v1_transcripts_search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get */ + get: operations["v1_transcript_get"]; + put?: never; + post?: never; + /** Transcript Delete */ + delete: operations["v1_transcript_delete"]; + options?: never; + head?: never; + /** Transcript Update */ + patch: operations["v1_transcript_update"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics */ + get: operations["v1_transcript_get_topics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics/with-words": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics With Words */ + get: operations["v1_transcript_get_topics_with_words"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics With Words Per Speaker */ + get: operations["v1_transcript_get_topics_with_words_per_speaker"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/zulip": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Post To Zulip */ + post: operations["v1_transcript_post_to_zulip"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/audio/mp3": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Audio Mp3 */ + get: operations["v1_transcript_get_audio_mp3"]; + put?: never; + post?: never; + delete?: never; + options?: never; + /** Transcript Get Audio Mp3 */ + head: operations["v1_transcript_head_audio_mp3"]; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/audio/waveform": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Audio Waveform */ + get: operations["v1_transcript_get_audio_waveform"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/participants": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Participants */ + get: operations["v1_transcript_get_participants"]; + put?: never; + /** Transcript Add Participant */ + post: operations["v1_transcript_add_participant"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/participants/{participant_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Participant */ + get: operations["v1_transcript_get_participant"]; + put?: never; + post?: never; + /** Transcript Delete Participant */ + delete: operations["v1_transcript_delete_participant"]; + options?: never; + head?: never; + /** Transcript Update Participant */ + patch: operations["v1_transcript_update_participant"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/speaker/assign": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Transcript Assign Speaker */ + patch: operations["v1_transcript_assign_speaker"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/speaker/merge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Transcript Merge Speaker */ + patch: operations["v1_transcript_merge_speaker"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/record/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Record Upload */ + post: operations["v1_transcript_record_upload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Websocket Events */ + get: operations["v1_transcript_get_websocket_events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/record/webrtc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Record Webrtc */ + post: operations["v1_transcript_record_webrtc"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/process": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Process */ + post: operations["v1_transcript_process"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** User Me */ + get: operations["v1_user_me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/user/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Api Keys */ + get: operations["v1_list_api_keys"]; + put?: never; + /** Create Api Key */ + post: operations["v1_create_api_key"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/user/api-keys/{key_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete Api Key */ + delete: operations["v1_delete_api_key"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/zulip/streams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Zulip Get Streams + * @description Get all Zulip streams. + */ + get: operations["v1_zulip_get_streams"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/zulip/streams/{stream_id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Zulip Get Topics + * @description Get all topics for a specific Zulip stream. + */ + get: operations["v1_zulip_get_topics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/whereby": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Whereby Webhook */ + post: operations["v1_whereby_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/webhook": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Webhook + * @description Handle Daily webhook events. + */ + post: operations["v1_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** ApiKeyResponse */ + ApiKeyResponse: { + /** + * Id + * @description A non-empty string + */ + id: string; + /** + * User Id + * @description A non-empty string + */ + user_id: string; + /** Name */ + name: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; + /** AudioWaveform */ + AudioWaveform: { + /** Data */ + data: number[]; + }; + /** Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post */ + Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post: { + /** + * Chunk + * Format: binary + */ + chunk: string; + }; + /** CalendarEventResponse */ + CalendarEventResponse: { + /** Id */ + id: string; + /** Room Id */ + room_id: string; + /** Ics Uid */ + ics_uid: string; + /** Title */ + title?: string | null; + /** Description */ + description?: string | null; + /** + * Start Time + * Format: date-time + */ + start_time: string; + /** + * End Time + * Format: date-time + */ + end_time: string; + /** Attendees */ + attendees?: + | { + [key: string]: unknown; + }[] + | null; + /** Location */ + location?: string | null; + /** + * Last Synced + * Format: date-time + */ + last_synced: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** CreateApiKeyRequest */ + CreateApiKeyRequest: { + /** Name */ + name?: string | null; + }; + /** CreateApiKeyResponse */ + CreateApiKeyResponse: { + /** + * Id + * @description A non-empty string + */ + id: string; + /** + * User Id + * @description A non-empty string + */ + user_id: string; + /** Name */ + name: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** + * Key + * @description A non-empty string + */ + key: string; + }; + /** CreateParticipant */ + CreateParticipant: { + /** Speaker */ + speaker?: number | null; + /** Name */ + name: string; + }; + /** CreateRoom */ + CreateRoom: { + /** Name */ + name: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Webhook Url */ + webhook_url: string; + /** Webhook Secret */ + webhook_secret: string; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + /** Platform */ + platform?: ("whereby" | "daily") | null; + }; + /** CreateRoomMeeting */ + CreateRoomMeeting: { + /** + * Allow Duplicated + * @default false + */ + allow_duplicated: boolean | null; + }; + /** CreateTranscript */ + CreateTranscript: { + /** Name */ + name: string; + /** + * Source Language + * @default en + */ + source_language: string; + /** + * Target Language + * @default en + */ + target_language: string; + source_kind?: components["schemas"]["SourceKind"] | null; + }; + /** + * DailyWebhookEvent + * @description Daily webhook event structure. + */ + DailyWebhookEvent: { + /** Type */ + type: string; + /** Id */ + id: string; + /** Ts */ + ts: number; + /** Data */ + data: { + [key: string]: unknown; + }; + }; + /** DeletionStatus */ + DeletionStatus: { + /** Status */ + status: string; + }; + /** GetTranscript */ + GetTranscript: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Participants */ + participants: components["schemas"]["TranscriptParticipant"][] | null; + }; + /** GetTranscriptMinimal */ + GetTranscriptMinimal: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + }; + /** GetTranscriptSegmentTopic */ + GetTranscriptSegmentTopic: { + /** Text */ + text: string; + /** Start */ + start: number; + /** Speaker */ + speaker: number; + }; + /** GetTranscriptTopic */ + GetTranscriptTopic: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + }; + /** GetTranscriptTopicWithWords */ + GetTranscriptTopicWithWords: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + /** + * Words + * @default [] + */ + words: components["schemas"]["Word"][]; + }; + /** GetTranscriptTopicWithWordsPerSpeaker */ + GetTranscriptTopicWithWordsPerSpeaker: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + /** + * Words Per Speaker + * @default [] + */ + words_per_speaker: components["schemas"]["SpeakerWords"][]; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** ICSStatus */ + ICSStatus: { + /** + * Status + * @enum {string} + */ + status: "enabled" | "disabled"; + /** Last Sync */ + last_sync?: string | null; + /** Next Sync */ + next_sync?: string | null; + /** Last Etag */ + last_etag?: string | null; + /** + * Events Count + * @default 0 + */ + events_count: number; + }; + /** ICSSyncResult */ + ICSSyncResult: { + status: components["schemas"]["SyncStatus"]; + /** Hash */ + hash?: string | null; + /** + * Events Found + * @default 0 + */ + events_found: number; + /** + * Total Events + * @default 0 + */ + total_events: number; + /** + * Events Created + * @default 0 + */ + events_created: number; + /** + * Events Updated + * @default 0 + */ + events_updated: number; + /** + * Events Deleted + * @default 0 + */ + events_deleted: number; + /** Error */ + error?: string | null; + /** Reason */ + reason?: string | null; + }; + /** Meeting */ + Meeting: { + /** Id */ + id: string; + /** Room Name */ + room_name: string; + /** Room Url */ + room_url: string; + /** Host Room Url */ + host_room_url: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** + * End Date + * Format: date-time + */ + end_date: string; + /** User Id */ + user_id?: string | null; + /** Room Id */ + room_id?: string | null; + /** + * Is Locked + * @default false + */ + is_locked: boolean; + /** + * Room Mode + * @default normal + * @enum {string} + */ + room_mode: "normal" | "group"; + /** + * Recording Type + * @default cloud + * @enum {string} + */ + recording_type: "none" | "local" | "cloud"; + /** + * Recording Trigger + * @default automatic-2nd-participant + * @enum {string} + */ + recording_trigger: + | "none" + | "prompt" + | "automatic" + | "automatic-2nd-participant"; + /** + * Num Clients + * @default 0 + */ + num_clients: number; + /** + * Is Active + * @default true + */ + is_active: boolean; + /** Calendar Event Id */ + calendar_event_id?: string | null; + /** Calendar Metadata */ + calendar_metadata?: { + [key: string]: unknown; + } | null; + /** + * Platform + * @default whereby + * @enum {string} + */ + platform: "whereby" | "daily"; + }; + /** MeetingConsentRequest */ + MeetingConsentRequest: { + /** Consent Given */ + consent_given: boolean; + }; + /** Page[GetTranscriptMinimal] */ + Page_GetTranscriptMinimal_: { + /** Items */ + items: components["schemas"]["GetTranscriptMinimal"][]; + /** Total */ + total?: number | null; + /** Page */ + page: number | null; + /** Size */ + size: number | null; + /** Pages */ + pages?: number | null; + }; + /** Page[RoomDetails] */ + Page_RoomDetails_: { + /** Items */ + items: components["schemas"]["RoomDetails"][]; + /** Total */ + total?: number | null; + /** Page */ + page: number | null; + /** Size */ + size: number | null; + /** Pages */ + pages?: number | null; + }; + /** Participant */ + Participant: { + /** Id */ + id: string; + /** Speaker */ + speaker: number | null; + /** Name */ + name: string; + }; + /** Room */ + Room: { + /** Id */ + id: string; + /** Name */ + name: string; + /** User Id */ + user_id: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + /** Ics Last Sync */ + ics_last_sync?: string | null; + /** Ics Last Etag */ + ics_last_etag?: string | null; + /** + * Platform + * @default whereby + * @enum {string} + */ + platform: "whereby" | "daily"; + }; + /** RoomDetails */ + RoomDetails: { + /** Id */ + id: string; + /** Name */ + name: string; + /** User Id */ + user_id: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Ics Url */ + ics_url?: string | null; + /** + * Ics Fetch Interval + * @default 300 + */ + ics_fetch_interval: number; + /** + * Ics Enabled + * @default false + */ + ics_enabled: boolean; + /** Ics Last Sync */ + ics_last_sync?: string | null; + /** Ics Last Etag */ + ics_last_etag?: string | null; + /** + * Platform + * @default whereby + * @enum {string} + */ + platform: "whereby" | "daily"; + /** Webhook Url */ + webhook_url: string | null; + /** Webhook Secret */ + webhook_secret: string | null; + }; + /** RtcOffer */ + RtcOffer: { + /** Sdp */ + sdp: string; + /** Type */ + type: string; + }; + /** SearchResponse */ + SearchResponse: { + /** Results */ + results: components["schemas"]["SearchResult"][]; + /** + * Total + * @description Total number of search results + */ + total: number; + /** Query */ + query?: string | null; + /** + * Limit + * @description Results per page + */ + limit: number; + /** + * Offset + * @description Number of results to skip + */ + offset: number; + }; + /** + * SearchResult + * @description Public search result model with computed fields. + */ + SearchResult: { + /** Id */ + id: string; + /** Title */ + title?: string | null; + /** User Id */ + user_id?: string | null; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Created At */ + created_at: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Rank */ + rank: number; + /** + * Duration + * @description Duration in seconds + */ + duration: number | null; + /** + * Search Snippets + * @description Text snippets around search matches + */ + search_snippets: string[]; + /** + * Total Match Count + * @description Total number of matches found in the transcript + * @default 0 + */ + total_match_count: number; + }; + /** + * SourceKind + * @enum {string} + */ + SourceKind: "room" | "live" | "file"; + /** SpeakerAssignment */ + SpeakerAssignment: { + /** Speaker */ + speaker?: number | null; + /** Participant */ + participant?: string | null; + /** Timestamp From */ + timestamp_from: number; + /** Timestamp To */ + timestamp_to: number; + }; + /** SpeakerAssignmentStatus */ + SpeakerAssignmentStatus: { + /** Status */ + status: string; + }; + /** SpeakerMerge */ + SpeakerMerge: { + /** Speaker From */ + speaker_from: number; + /** Speaker To */ + speaker_to: number; + }; + /** SpeakerWords */ + SpeakerWords: { + /** Speaker */ + speaker: number; + /** Words */ + words: components["schemas"]["Word"][]; + }; + /** Stream */ + Stream: { + /** Stream Id */ + stream_id: number; + /** Name */ + name: string; + }; + /** + * SyncStatus + * @enum {string} + */ + SyncStatus: "success" | "unchanged" | "error" | "skipped"; + /** Topic */ + Topic: { + /** Name */ + name: string; + }; + /** TranscriptParticipant */ + TranscriptParticipant: { + /** Id */ + id?: string; + /** Speaker */ + speaker: number | null; + /** Name */ + name: string; + }; + /** UpdateParticipant */ + UpdateParticipant: { + /** Speaker */ + speaker?: number | null; + /** Name */ + name?: string | null; + }; + /** UpdateRoom */ + UpdateRoom: { + /** Name */ + name?: string | null; + /** Zulip Auto Post */ + zulip_auto_post?: boolean | null; + /** Zulip Stream */ + zulip_stream?: string | null; + /** Zulip Topic */ + zulip_topic?: string | null; + /** Is Locked */ + is_locked?: boolean | null; + /** Room Mode */ + room_mode?: string | null; + /** Recording Type */ + recording_type?: string | null; + /** Recording Trigger */ + recording_trigger?: string | null; + /** Is Shared */ + is_shared?: boolean | null; + /** Webhook Url */ + webhook_url?: string | null; + /** Webhook Secret */ + webhook_secret?: string | null; + /** Ics Url */ + ics_url?: string | null; + /** Ics Fetch Interval */ + ics_fetch_interval?: number | null; + /** Ics Enabled */ + ics_enabled?: boolean | null; + /** Platform */ + platform?: ("whereby" | "daily") | null; + }; + /** UpdateTranscript */ + UpdateTranscript: { + /** Name */ + name?: string | null; + /** Locked */ + locked?: boolean | null; + /** Title */ + title?: string | null; + /** Short Summary */ + short_summary?: string | null; + /** Long Summary */ + long_summary?: string | null; + /** Share Mode */ + share_mode?: ("public" | "semi-private" | "private") | null; + /** Participants */ + participants?: components["schemas"]["TranscriptParticipant"][] | null; + /** Reviewed */ + reviewed?: boolean | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + }; + /** UserInfo */ + UserInfo: { + /** Sub */ + sub: string; + /** Email */ + email: string | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + /** WebhookTestResult */ + WebhookTestResult: { + /** Success */ + success: boolean; + /** + * Message + * @default + */ + message: string; + /** + * Error + * @default + */ + error: string; + /** Status Code */ + status_code?: number | null; + /** Response Preview */ + response_preview?: string | null; + }; + /** WherebyWebhookEvent */ + WherebyWebhookEvent: { + /** Apiversion */ + apiVersion: string; + /** Id */ + id: string; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** Type */ + type: string; + /** Data */ + data: { + [key: string]: unknown; + }; + }; + /** Word */ + Word: { + /** Text */ + text: string; + /** + * Start + * @description Time in seconds with float part + */ + start: number; + /** + * End + * @description Time in seconds with float part + */ + end: number; + /** + * Speaker + * @default 0 + */ + speaker: number; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + health: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + metrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + v1_meeting_audio_consent: { + parameters: { + query?: never; + header?: never; + path: { + meeting_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MeetingConsentRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_meeting_deactivate: { + parameters: { + query?: never; + header?: never; + path: { + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Page size */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Page_RoomDetails_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRoom"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_get: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_delete: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_update: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateRoom"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_get_by_name: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_create_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRoomMeeting"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_test_webhook: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookTestResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_sync_ics: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ICSSyncResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_ics_status: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ICSStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_meetings: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CalendarEventResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_upcoming_meetings: { + parameters: { + query?: { + minutes_ahead?: number; + }; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CalendarEventResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list_active_meetings: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_get_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_join_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + meeting_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_list: { + parameters: { + query?: { + source_kind?: components["schemas"]["SourceKind"] | null; + room_id?: string | null; + search_term?: string | null; + /** @description Page number */ + page?: number; + /** @description Page size */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Page_GetTranscriptMinimal_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTranscript"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscript"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_search: { + parameters: { + query: { + /** @description Search query text */ + q: string; + /** @description Results per page */ + limit?: number; + /** @description Number of results to skip */ + offset?: number; + room_id?: string | null; + source_kind?: components["schemas"]["SourceKind"] | null; + /** @description Filter transcripts created on or after this datetime (ISO 8601 with timezone) */ + from?: string | null; + /** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */ + to?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscript"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_delete: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_update: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTranscript"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscript"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopic"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics_with_words: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopicWithWords"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics_with_words_per_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + topic_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopicWithWordsPerSpeaker"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_post_to_zulip: { + parameters: { + query: { + stream: string; + topic: string; + include_topics: boolean; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_audio_mp3: { + parameters: { + query?: { + token?: string | null; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_head_audio_mp3: { + parameters: { + query?: { + token?: string | null; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_audio_waveform: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AudioWaveform"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_participants: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_add_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateParticipant"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_delete_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_update_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateParticipant"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_assign_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SpeakerAssignment"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpeakerAssignmentStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_merge_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SpeakerMerge"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpeakerAssignmentStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_record_upload: { + parameters: { + query: { + chunk_number: number; + total_chunks: number; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_websocket_events: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_record_webrtc: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RtcOffer"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_process: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_user_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserInfo"] | null; + }; + }; + }; + }; + v1_list_api_keys: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiKeyResponse"][]; + }; + }; + }; + }; + v1_create_api_key: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateApiKeyRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateApiKeyResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_delete_api_key: { + parameters: { + query?: never; + header?: never; + path: { + key_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_zulip_get_streams: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Stream"][]; + }; + }; + }; + }; + v1_zulip_get_topics: { + parameters: { + query?: never; + header?: never; + path: { + stream_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Topic"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_whereby_webhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WherebyWebhookEvent"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_webhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DailyWebhookEvent"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/www/app/webinars/[title]/page.tsx b/www/app/webinars/[title]/page.tsx index ab873a6b..ff21af1e 100644 --- a/www/app/webinars/[title]/page.tsx +++ b/www/app/webinars/[title]/page.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, use } from "react"; import Link from "next/link"; import Image from "next/image"; import { notFound } from "next/navigation"; -import useRoomMeeting from "../../[roomName]/useRoomMeeting"; +import useRoomDefaultMeeting from "../../[roomName]/useRoomDefaultMeeting"; import dynamic from "next/dynamic"; const WherebyEmbed = dynamic(() => import("../../lib/WherebyWebinarEmbed"), { ssr: false, @@ -30,9 +30,9 @@ const FORM_FIELDS = { }; export type WebinarDetails = { - params: { + params: Promise<{ title: string; - }; + }>; }; export type Webinar = { @@ -63,7 +63,8 @@ const WEBINARS: Webinar[] = [ ]; export default function WebinarPage(details: WebinarDetails) { - const title = details.params.title; + const params = use(details.params); + const title = params.title; const webinar = WEBINARS.find((webinar) => webinar.title === title); if (!webinar) { return notFound(); @@ -71,7 +72,7 @@ export default function WebinarPage(details: WebinarDetails) { const startDate = new Date(Date.parse(webinar.startsAt)); const endDate = new Date(Date.parse(webinar.endsAt)); - const meeting = useRoomMeeting(ROOM_NAME); + const meeting = useRoomDefaultMeeting(ROOM_NAME); const roomUrl = meeting?.response?.host_room_url ? meeting?.response?.host_room_url : meeting?.response?.room_url; diff --git a/www/config-template.ts b/www/config-template.ts deleted file mode 100644 index e8d4c01c..00000000 --- a/www/config-template.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const localConfig = { - features: { - requireLogin: true, - privacy: true, - browse: true, - sendToZulip: true, - rooms: true, - }, - api_url: "http://127.0.0.1:1250", - websocket_url: "ws://127.0.0.1:1250", - auth_callback_url: "http://localhost:3000/auth-callback", - zulip_streams: "", // Find the value on zulip -}; diff --git a/www/sentry.client.config.ts b/www/instrumentation-client.ts similarity index 91% rename from www/sentry.client.config.ts rename to www/instrumentation-client.ts index aff65bbd..5ea5e2e9 100644 --- a/www/sentry.client.config.ts +++ b/www/instrumentation-client.ts @@ -23,3 +23,5 @@ if (SENTRY_DSN) { replaysSessionSampleRate: 0.0, }); } + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/www/instrumentation.ts b/www/instrumentation.ts new file mode 100644 index 00000000..f8a929ba --- /dev/null +++ b/www/instrumentation.ts @@ -0,0 +1,9 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + await import("./sentry.server.config"); + } + + if (process.env.NEXT_RUNTIME === "edge") { + await import("./sentry.edge.config"); + } +} diff --git a/www/jest.config.js b/www/jest.config.js new file mode 100644 index 00000000..d2f3247b --- /dev/null +++ b/www/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/app"], + testMatch: ["**/__tests__/**/*.test.ts"], + collectCoverage: true, + collectCoverageFrom: ["app/**/*.ts", "!app/**/*.d.ts"], +}; diff --git a/www/middleware.ts b/www/middleware.ts index 39145220..7f487cd2 100644 --- a/www/middleware.ts +++ b/www/middleware.ts @@ -1,16 +1,7 @@ import { withAuth } from "next-auth/middleware"; -import { getConfig } from "./app/lib/edgeConfig"; +import { featureEnabled } from "./app/lib/features"; import { NextResponse } from "next/server"; - -const LOGIN_REQUIRED_PAGES = [ - "/transcripts/[!new]", - "/browse(.*)", - "/rooms(.*)", -]; - -const PROTECTED_PAGES = new RegExp( - LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"), -); +import { PROTECTED_PAGES } from "./app/lib/auth"; export const config = { matcher: [ @@ -28,13 +19,12 @@ export const config = { export default withAuth( async function middleware(request) { - const config = await getConfig(); const pathname = request.nextUrl.pathname; // feature-flags protected paths if ( - (!config.features.browse && pathname.startsWith("/browse")) || - (!config.features.rooms && pathname.startsWith("/rooms")) + (!featureEnabled("browse") && pathname.startsWith("/browse")) || + (!featureEnabled("rooms") && pathname.startsWith("/rooms")) ) { return NextResponse.redirect(request.nextUrl.origin); } @@ -42,10 +32,8 @@ export default withAuth( { callbacks: { async authorized({ req, token }) { - const config = await getConfig(); - if ( - config.features.requireLogin && + featureEnabled("requireLogin") && PROTECTED_PAGES.test(req.nextUrl.pathname) ) { return !!token; diff --git a/www/next.config.js b/www/next.config.js index e37d5402..eedbac7f 100644 --- a/www/next.config.js +++ b/www/next.config.js @@ -1,7 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: "standalone", - experimental: { esmExternals: "loose" }, + env: { + IS_CI: process.env.IS_CI, + }, }; module.exports = nextConfig; diff --git a/www/openapi-ts.config.ts b/www/openapi-ts.config.ts deleted file mode 100644 index 9304b8f7..00000000 --- a/www/openapi-ts.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "@hey-api/openapi-ts"; - -export default defineConfig({ - client: "axios", - name: "OpenApi", - input: "http://127.0.0.1:1250/openapi.json", - output: { - path: "./app/api", - format: "prettier", - }, - services: { - asClass: true, - }, -}); diff --git a/www/package.json b/www/package.json index 482a29f6..f4412db0 100644 --- a/www/package.json +++ b/www/package.json @@ -5,33 +5,38 @@ "scripts": { "dev": "next dev", "build": "next build", + "build-production": "next build --experimental-build-mode compile", "start": "next start", "lint": "next lint", "format": "prettier --write .", - "openapi": "openapi-ts" + "openapi": "openapi-typescript http://127.0.0.1:1250/openapi.json -o ./app/reflector-api.d.ts", + "test": "jest" }, "dependencies": { "@chakra-ui/react": "^3.24.2", + "@daily-co/daily-js": "^0.84.0", "@emotion/react": "^11.14.0", "@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/react-fontawesome": "^0.2.0", - "@sentry/nextjs": "^7.77.0", - "@vercel/edge-config": "^0.4.1", - "@vercel/kv": "^2.0.0", + "@sentry/nextjs": "^10.11.0", + "@tanstack/react-query": "^5.85.9", + "@types/ioredis": "^5.0.0", "@whereby.com/browser-sdk": "^3.3.4", "autoprefixer": "10.4.20", "axios": "^1.8.2", "eslint": "^9.33.0", - "eslint-config-next": "^14.2.31", + "eslint-config-next": "^15.5.3", "fontawesome": "^5.6.3", - "ioredis": "^5.4.1", + "ioredis": "^5.7.0", "jest-worker": "^29.6.2", "lucide-react": "^0.525.0", - "next": "^14.2.30", + "next": "^15.5.3", "next-auth": "^4.24.7", "next-themes": "^0.4.6", "nuqs": "^2.4.3", + "openapi-fetch": "^0.14.0", + "openapi-react-query": "^0.5.0", "postcss": "8.4.31", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -41,22 +46,26 @@ "react-markdown": "^9.0.0", "react-qr-code": "^2.0.12", "react-select-search": "^4.1.7", - "redlock": "^5.0.0-beta.2", + "redlock": "5.0.0-beta.2", + "remeda": "^2.31.1", "sass": "^1.63.6", "simple-peer": "^9.11.1", "tailwindcss": "^3.3.2", "typescript": "^5.1.6", - "wavesurfer.js": "^7.4.2" + "wavesurfer.js": "^7.4.2", + "zod": "^4.1.5" }, "main": "index.js", "repository": "https://github.com/Monadical-SAS/reflector-ui.git", "author": "Andreas ", "license": "All Rights Reserved", "devDependencies": { - "@hey-api/openapi-ts": "^0.48.0", + "@types/jest": "^30.0.0", "@types/react": "18.2.20", + "jest": "^30.1.3", + "openapi-typescript": "^7.9.1", "prettier": "^3.0.0", - "vercel": "^37.3.0" + "ts-jest": "^29.4.1" }, "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748" } diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index 55aef9c8..92667b7e 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: "@chakra-ui/react": specifier: ^3.24.2 version: 3.24.2(@emotion/react@11.14.0(@types/react@18.2.20)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + "@daily-co/daily-js": + specifier: ^0.84.0 + version: 0.84.0 "@emotion/react": specifier: ^11.14.0 version: 11.14.0(@types/react@18.2.20)(react@18.3.1) @@ -23,14 +26,14 @@ importers: specifier: ^0.2.0 version: 0.2.3(@fortawesome/fontawesome-svg-core@6.7.2)(react@18.3.1) "@sentry/nextjs": - specifier: ^7.77.0 - version: 7.120.4(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) - "@vercel/edge-config": - specifier: ^0.4.1 - version: 0.4.1 - "@vercel/kv": - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^10.11.0 + version: 10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3) + "@tanstack/react-query": + specifier: ^5.85.9 + version: 5.85.9(react@18.3.1) + "@types/ioredis": + specifier: ^5.0.0 + version: 5.0.0 "@whereby.com/browser-sdk": specifier: ^3.3.4 version: 3.13.1(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -44,13 +47,13 @@ importers: specifier: ^9.33.0 version: 9.33.0(jiti@1.21.7) eslint-config-next: - specifier: ^14.2.31 - version: 14.2.31(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2) + specifier: ^15.5.3 + version: 15.5.3(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2) fontawesome: specifier: ^5.6.3 version: 5.6.3 ioredis: - specifier: ^5.4.1 + specifier: ^5.7.0 version: 5.7.0 jest-worker: specifier: ^29.6.2 @@ -59,17 +62,23 @@ importers: specifier: ^0.525.0 version: 0.525.0(react@18.3.1) next: - specifier: ^14.2.30 - version: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + specifier: ^15.5.3 + version: 15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) next-auth: specifier: ^4.24.7 - version: 4.24.11(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.11(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) + version: 2.4.3(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) + openapi-fetch: + specifier: ^0.14.0 + version: 0.14.0 + openapi-react-query: + specifier: ^0.5.0 + version: 0.5.0(@tanstack/react-query@5.85.9(react@18.3.1))(openapi-fetch@0.14.0) postcss: specifier: 8.4.31 version: 8.4.31 @@ -98,8 +107,11 @@ importers: specifier: ^4.1.7 version: 4.1.8(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) redlock: - specifier: ^5.0.0-beta.2 + specifier: 5.0.0-beta.2 version: 5.0.0-beta.2 + remeda: + specifier: ^2.31.1 + version: 2.31.1 sass: specifier: ^1.63.6 version: 1.90.0 @@ -108,26 +120,35 @@ importers: version: 9.11.1 tailwindcss: specifier: ^3.3.2 - version: 3.4.17(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + version: 3.4.17(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) typescript: specifier: ^5.1.6 version: 5.9.2 wavesurfer.js: specifier: ^7.4.2 version: 7.10.1 + zod: + specifier: ^4.1.5 + version: 4.1.5 devDependencies: - "@hey-api/openapi-ts": - specifier: ^0.48.0 - version: 0.48.3(typescript@5.9.2) + "@types/jest": + specifier: ^30.0.0 + version: 30.0.0 "@types/react": specifier: 18.2.20 version: 18.2.20 + jest: + specifier: ^30.1.3 + version: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) + openapi-typescript: + specifier: ^7.9.1 + version: 7.9.1(typescript@5.9.2) prettier: specifier: ^3.0.0 version: 3.6.2 - vercel: - specifier: ^37.3.0 - version: 37.14.0 + ts-jest: + specifier: ^29.4.1 + version: 29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)))(typescript@5.9.2) packages: "@alloc/quick-lru@5.2.0": @@ -137,12 +158,12 @@ packages: } engines: { node: ">=10" } - "@apidevtools/json-schema-ref-parser@11.6.4": + "@ampproject/remapping@2.3.0": resolution: { - integrity: sha512-9K6xOqeevacvweLGik6LnZCb1fBtCOSIWQs8d096XGeqoLKC33UVMGz9+77Gw44KvbH4pKcQPWo4ZpxkXYj05w==, + integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, } - engines: { node: ">= 16" } + engines: { node: ">=6.0.0" } "@ark-ui/react@5.18.2": resolution: @@ -160,6 +181,20 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/compat-data@7.28.0": + resolution: + { + integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==, + } + engines: { node: ">=6.9.0" } + + "@babel/core@7.28.3": + resolution: + { + integrity: sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==, + } + engines: { node: ">=6.9.0" } + "@babel/generator@7.28.0": resolution: { @@ -167,6 +202,20 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/generator@7.28.3": + resolution: + { + integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==, + } + engines: { node: ">=6.9.0" } + + "@babel/helper-compilation-targets@7.27.2": + resolution: + { + integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==, + } + engines: { node: ">=6.9.0" } + "@babel/helper-globals@7.28.0": resolution: { @@ -181,6 +230,22 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/helper-module-transforms@7.28.3": + resolution: + { + integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0 + + "@babel/helper-plugin-utils@7.27.1": + resolution: + { + integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==, + } + engines: { node: ">=6.9.0" } + "@babel/helper-string-parser@7.27.1": resolution: { @@ -195,6 +260,20 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/helper-validator-option@7.27.1": + resolution: + { + integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==, + } + engines: { node: ">=6.9.0" } + + "@babel/helpers@7.28.3": + resolution: + { + integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==, + } + engines: { node: ">=6.9.0" } + "@babel/parser@7.28.0": resolution: { @@ -203,6 +282,156 @@ packages: engines: { node: ">=6.0.0" } hasBin: true + "@babel/parser@7.28.3": + resolution: + { + integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==, + } + engines: { node: ">=6.0.0" } + hasBin: true + + "@babel/plugin-syntax-async-generators@7.8.4": + resolution: + { + integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-bigint@7.8.3": + resolution: + { + integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-class-properties@7.12.13": + resolution: + { + integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-class-static-block@7.14.5": + resolution: + { + integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-import-attributes@7.27.1": + resolution: + { + integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-import-meta@7.10.4": + resolution: + { + integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-json-strings@7.8.3": + resolution: + { + integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-jsx@7.27.1": + resolution: + { + integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-logical-assignment-operators@7.10.4": + resolution: + { + integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3": + resolution: + { + integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-numeric-separator@7.10.4": + resolution: + { + integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-object-rest-spread@7.8.3": + resolution: + { + integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-optional-catch-binding@7.8.3": + resolution: + { + integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-optional-chaining@7.8.3": + resolution: + { + integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-private-property-in-object@7.14.5": + resolution: + { + integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-top-level-await@7.14.5": + resolution: + { + integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-typescript@7.27.1": + resolution: + { + integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + "@babel/runtime@7.28.2": resolution: { @@ -224,6 +453,13 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/traverse@7.28.3": + resolution: + { + integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==, + } + engines: { node: ">=6.9.0" } + "@babel/types@7.28.2": resolution: { @@ -231,6 +467,12 @@ packages: } engines: { node: ">=6.9.0" } + "@bcoe/v8-coverage@0.2.3": + resolution: + { + integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, + } + "@chakra-ui/react@3.24.2": resolution: { @@ -248,40 +490,12 @@ packages: } engines: { node: ">=12" } - "@edge-runtime/format@2.2.1": + "@daily-co/daily-js@0.84.0": resolution: { - integrity: sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==, + integrity: sha512-/ynXrMDDkRXhLlHxiFNf9QU5yw4ZGPr56wNARgja/Tiid71UIniundTavCNF5cMb2I1vNoMh7oEJ/q8stg/V7g==, } - engines: { node: ">=16" } - - "@edge-runtime/node-utils@2.3.0": - resolution: - { - integrity: sha512-uUtx8BFoO1hNxtHjp3eqVPC/mWImGb2exOfGjMLUoipuWgjej+f4o/VP4bUI8U40gu7Teogd5VTeZUkGvJSPOQ==, - } - engines: { node: ">=16" } - - "@edge-runtime/ponyfill@2.4.2": - resolution: - { - integrity: sha512-oN17GjFr69chu6sDLvXxdhg0Qe8EZviGSuqzR9qOiKh4MhFYGdBBcqRNzdmYeAdeRzOW2mM9yil4RftUQ7sUOA==, - } - engines: { node: ">=16" } - - "@edge-runtime/primitives@4.1.0": - resolution: - { - integrity: sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==, - } - engines: { node: ">=16" } - - "@edge-runtime/vm@3.2.0": - resolution: - { - integrity: sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==, - } - engines: { node: ">=16" } + engines: { node: ">=10.0.0" } "@emnapi/core@1.4.5": resolution: @@ -446,13 +660,6 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - "@fastify/busboy@2.1.1": - resolution: - { - integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==, - } - engines: { node: ">=14" } - "@floating-ui/core@1.7.3": resolution: { @@ -516,16 +723,6 @@ packages: "@fortawesome/fontawesome-svg-core": ~1 || ~6 || ~7 react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0 - "@hey-api/openapi-ts@0.48.3": - resolution: - { - integrity: sha512-R53Nr4Gicz77icS+RiH0fwHa9A0uFPtzsjC8SBaGwtOel5ZyxeBbayWE6HhE789hp3dok9pegwWncwwOrr4WFA==, - } - engines: { node: ^18.0.0 || >=20.0.0 } - hasBin: true - peerDependencies: - typescript: ^5.x - "@humanfs/core@0.19.1": resolution: { @@ -561,6 +758,194 @@ packages: } engines: { node: ">=18.18" } + "@img/sharp-darwin-arm64@0.34.3": + resolution: + { + integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [darwin] + + "@img/sharp-darwin-x64@0.34.3": + resolution: + { + integrity: sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [darwin] + + "@img/sharp-libvips-darwin-arm64@1.2.0": + resolution: + { + integrity: sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==, + } + cpu: [arm64] + os: [darwin] + + "@img/sharp-libvips-darwin-x64@1.2.0": + resolution: + { + integrity: sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==, + } + cpu: [x64] + os: [darwin] + + "@img/sharp-libvips-linux-arm64@1.2.0": + resolution: + { + integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==, + } + cpu: [arm64] + os: [linux] + + "@img/sharp-libvips-linux-arm@1.2.0": + resolution: + { + integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==, + } + cpu: [arm] + os: [linux] + + "@img/sharp-libvips-linux-ppc64@1.2.0": + resolution: + { + integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==, + } + cpu: [ppc64] + os: [linux] + + "@img/sharp-libvips-linux-s390x@1.2.0": + resolution: + { + integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==, + } + cpu: [s390x] + os: [linux] + + "@img/sharp-libvips-linux-x64@1.2.0": + resolution: + { + integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==, + } + cpu: [x64] + os: [linux] + + "@img/sharp-libvips-linuxmusl-arm64@1.2.0": + resolution: + { + integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==, + } + cpu: [arm64] + os: [linux] + + "@img/sharp-libvips-linuxmusl-x64@1.2.0": + resolution: + { + integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==, + } + cpu: [x64] + os: [linux] + + "@img/sharp-linux-arm64@0.34.3": + resolution: + { + integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [linux] + + "@img/sharp-linux-arm@0.34.3": + resolution: + { + integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm] + os: [linux] + + "@img/sharp-linux-ppc64@0.34.3": + resolution: + { + integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [ppc64] + os: [linux] + + "@img/sharp-linux-s390x@0.34.3": + resolution: + { + integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [s390x] + os: [linux] + + "@img/sharp-linux-x64@0.34.3": + resolution: + { + integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [linux] + + "@img/sharp-linuxmusl-arm64@0.34.3": + resolution: + { + integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [linux] + + "@img/sharp-linuxmusl-x64@0.34.3": + resolution: + { + integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [linux] + + "@img/sharp-wasm32@0.34.3": + resolution: + { + integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [wasm32] + + "@img/sharp-win32-arm64@0.34.3": + resolution: + { + integrity: sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [win32] + + "@img/sharp-win32-ia32@0.34.3": + resolution: + { + integrity: sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [ia32] + os: [win32] + + "@img/sharp-win32-x64@0.34.3": + resolution: + { + integrity: sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [win32] + "@internationalized/date@3.8.2": resolution: { @@ -573,10 +958,10 @@ packages: integrity: sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==, } - "@ioredis/commands@1.3.0": + "@ioredis/commands@1.3.1": resolution: { - integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==, + integrity: sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==, } "@isaacs/cliui@8.0.2": @@ -586,6 +971,107 @@ packages: } engines: { node: ">=12" } + "@istanbuljs/load-nyc-config@1.1.0": + resolution: + { + integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==, + } + engines: { node: ">=8" } + + "@istanbuljs/schema@0.1.3": + resolution: + { + integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==, + } + engines: { node: ">=8" } + + "@jest/console@30.1.2": + resolution: + { + integrity: sha512-BGMAxj8VRmoD0MoA/jo9alMXSRoqW8KPeqOfEo1ncxnRLatTBCpRoOwlwlEMdudp68Q6WSGwYrrLtTGOh8fLzw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/core@30.1.3": + resolution: + { + integrity: sha512-LIQz7NEDDO1+eyOA2ZmkiAyYvZuo6s1UxD/e2IHldR6D7UYogVq3arTmli07MkENLq6/3JEQjp0mA8rrHHJ8KQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + "@jest/diff-sequences@30.0.1": + resolution: + { + integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/environment@30.1.2": + resolution: + { + integrity: sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/expect-utils@30.1.2": + resolution: + { + integrity: sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/expect@30.1.2": + resolution: + { + integrity: sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/fake-timers@30.1.2": + resolution: + { + integrity: sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/get-type@30.1.0": + resolution: + { + integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/globals@30.1.2": + resolution: + { + integrity: sha512-teNTPZ8yZe3ahbYnvnVRDeOjr+3pu2uiAtNtrEsiMjVPPj+cXd5E/fr8BL7v/T7F31vYdEHrI5cC/2OoO/vM9A==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/pattern@30.0.1": + resolution: + { + integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/reporters@30.1.3": + resolution: + { + integrity: sha512-VWEQmJWfXMOrzdFEOyGjUEOuVXllgZsoPtEHZzfdNz18RmzJ5nlR6kp8hDdY8dDS1yGOXAY7DHT+AOHIPSBV0w==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + "@jest/schemas@29.6.3": resolution: { @@ -593,6 +1079,48 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + "@jest/schemas@30.0.5": + resolution: + { + integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/snapshot-utils@30.1.2": + resolution: + { + integrity: sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/source-map@30.0.1": + resolution: + { + integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/test-result@30.1.3": + resolution: + { + integrity: sha512-P9IV8T24D43cNRANPPokn7tZh0FAFnYS2HIfi5vK18CjRkTDR9Y3e1BoEcAJnl4ghZZF4Ecda4M/k41QkvurEQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/test-sequencer@30.1.3": + resolution: + { + integrity: sha512-82J+hzC0qeQIiiZDThh+YUadvshdBswi5nuyXlEmXzrhw5ZQSRHeQ5LpVMD/xc8B3wPePvs6VMzHnntxL+4E3w==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/transform@30.1.2": + resolution: + { + integrity: sha512-UYYFGifSgfjujf1Cbd3iU/IQoSd6uwsj8XHj5DSDf5ERDcWMdJOPTkHWXj4U+Z/uMagyOQZ6Vne8C4nRIrCxqA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + "@jest/types@29.6.3": resolution: { @@ -600,6 +1128,13 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + "@jest/types@30.0.5": + resolution: + { + integrity: sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + "@jridgewell/gen-mapping@0.3.13": resolution: { @@ -613,6 +1148,12 @@ packages: } engines: { node: ">=6.0.0" } + "@jridgewell/source-map@0.3.11": + resolution: + { + integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==, + } + "@jridgewell/sourcemap-codec@1.5.5": resolution: { @@ -625,119 +1166,103 @@ packages: integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==, } + "@jridgewell/trace-mapping@0.3.31": + resolution: + { + integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, + } + "@jridgewell/trace-mapping@0.3.9": resolution: { integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, } - "@jsdevtools/ono@7.1.3": - resolution: - { - integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==, - } - - "@mapbox/node-pre-gyp@1.0.11": - resolution: - { - integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==, - } - hasBin: true - "@napi-rs/wasm-runtime@0.2.12": resolution: { integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==, } - "@next/env@14.2.31": + "@next/env@15.5.3": resolution: { - integrity: sha512-X8VxxYL6VuezrG82h0pUA1V+DuTSJp7Nv15bxq3ivrFqZLjx81rfeHMWOE9T0jm1n3DtHGv8gdn6B0T0kr0D3Q==, + integrity: sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==, } - "@next/eslint-plugin-next@14.2.31": + "@next/eslint-plugin-next@15.5.3": resolution: { - integrity: sha512-ouaB+l8Cr/uzGxoGHUvd01OnfFTM8qM81Crw1AG0xoWDRN0DKLXyTWVe0FdAOHVBpGuXB87aufdRmrwzZDArIw==, + integrity: sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==, } - "@next/swc-darwin-arm64@14.2.31": + "@next/swc-darwin-arm64@15.5.3": resolution: { - integrity: sha512-dTHKfaFO/xMJ3kzhXYgf64VtV6MMwDs2viedDOdP+ezd0zWMOQZkxcwOfdcQeQCpouTr9b+xOqMCUXxgLizl8Q==, + integrity: sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==, } engines: { node: ">= 10" } cpu: [arm64] os: [darwin] - "@next/swc-darwin-x64@14.2.31": + "@next/swc-darwin-x64@15.5.3": resolution: { - integrity: sha512-iSavebQgeMukUAfjfW8Fi2Iz01t95yxRl2w2wCzjD91h5In9la99QIDKcKSYPfqLjCgwz3JpIWxLG6LM/sxL4g==, + integrity: sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==, } engines: { node: ">= 10" } cpu: [x64] os: [darwin] - "@next/swc-linux-arm64-gnu@14.2.31": + "@next/swc-linux-arm64-gnu@15.5.3": resolution: { - integrity: sha512-XJb3/LURg1u1SdQoopG6jDL2otxGKChH2UYnUTcby4izjM0il7ylBY5TIA7myhvHj9lG5pn9F2nR2s3i8X9awQ==, + integrity: sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==, } engines: { node: ">= 10" } cpu: [arm64] os: [linux] - "@next/swc-linux-arm64-musl@14.2.31": + "@next/swc-linux-arm64-musl@15.5.3": resolution: { - integrity: sha512-IInDAcchNCu3BzocdqdCv1bKCmUVO/bKJHnBFTeq3svfaWpOPewaLJ2Lu3GL4yV76c/86ZvpBbG/JJ1lVIs5MA==, + integrity: sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==, } engines: { node: ">= 10" } cpu: [arm64] os: [linux] - "@next/swc-linux-x64-gnu@14.2.31": + "@next/swc-linux-x64-gnu@15.5.3": resolution: { - integrity: sha512-YTChJL5/9e4NXPKW+OJzsQa42RiWUNbE+k+ReHvA+lwXk+bvzTsVQboNcezWOuCD+p/J+ntxKOB/81o0MenBhw==, + integrity: sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==, } engines: { node: ">= 10" } cpu: [x64] os: [linux] - "@next/swc-linux-x64-musl@14.2.31": + "@next/swc-linux-x64-musl@15.5.3": resolution: { - integrity: sha512-A0JmD1y4q/9ufOGEAhoa60Sof++X10PEoiWOH0gZ2isufWZeV03NnyRlRmJpRQWGIbRkJUmBo9I3Qz5C10vx4w==, + integrity: sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==, } engines: { node: ">= 10" } cpu: [x64] os: [linux] - "@next/swc-win32-arm64-msvc@14.2.31": + "@next/swc-win32-arm64-msvc@15.5.3": resolution: { - integrity: sha512-nowJ5GbMeDOMzbTm29YqrdrD6lTM8qn2wnZfGpYMY7SZODYYpaJHH1FJXE1l1zWICHR+WfIMytlTDBHu10jb8A==, + integrity: sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==, } engines: { node: ">= 10" } cpu: [arm64] os: [win32] - "@next/swc-win32-ia32-msvc@14.2.31": + "@next/swc-win32-x64-msvc@15.5.3": resolution: { - integrity: sha512-pk9Bu4K0015anTS1OS9d/SpS0UtRObC+xe93fwnm7Gvqbv/W1ZbzhK4nvc96RURIQOux3P/bBH316xz8wjGSsA==, - } - engines: { node: ">= 10" } - cpu: [ia32] - os: [win32] - - "@next/swc-win32-x64-msvc@14.2.31": - resolution: - { - integrity: sha512-LwFZd4JFnMHGceItR9+jtlMm8lGLU/IPkgjBBgYmdYSfalbHCiDpjMYtgDQ2wtwiAOSJOCyFI4m8PikrsDyA6Q==, + integrity: sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==, } engines: { node: ">= 10" } cpu: [x64] @@ -771,6 +1296,327 @@ packages: } engines: { node: ">=12.4.0" } + "@opentelemetry/api-logs@0.203.0": + resolution: + { + integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==, + } + engines: { node: ">=8.0.0" } + + "@opentelemetry/api-logs@0.204.0": + resolution: + { + integrity: sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw==, + } + engines: { node: ">=8.0.0" } + + "@opentelemetry/api-logs@0.57.2": + resolution: + { + integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==, + } + engines: { node: ">=14" } + + "@opentelemetry/api@1.9.0": + resolution: + { + integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==, + } + engines: { node: ">=8.0.0" } + + "@opentelemetry/context-async-hooks@2.1.0": + resolution: + { + integrity: sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + + "@opentelemetry/core@2.0.1": + resolution: + { + integrity: sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + + "@opentelemetry/core@2.1.0": + resolution: + { + integrity: sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + + "@opentelemetry/instrumentation-amqplib@0.50.0": + resolution: + { + integrity: sha512-kwNs/itehHG/qaQBcVrLNcvXVPW0I4FCOVtw3LHMLdYIqD7GJ6Yv2nX+a4YHjzbzIeRYj8iyMp0Bl7tlkidq5w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-connect@0.47.0": + resolution: + { + integrity: sha512-pjenvjR6+PMRb6/4X85L4OtkQCootgb/Jzh/l/Utu3SJHBid1F+gk9sTGU2FWuhhEfV6P7MZ7BmCdHXQjgJ42g==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-dataloader@0.21.1": + resolution: + { + integrity: sha512-hNAm/bwGawLM8VDjKR0ZUDJ/D/qKR3s6lA5NV+btNaPVm2acqhPcT47l2uCVi+70lng2mywfQncor9v8/ykuyw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-express@0.52.0": + resolution: + { + integrity: sha512-W7pizN0Wh1/cbNhhTf7C62NpyYw7VfCFTYg0DYieSTrtPBT1vmoSZei19wfKLnrMsz3sHayCg0HxCVL2c+cz5w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-fs@0.23.0": + resolution: + { + integrity: sha512-Puan+QopWHA/KNYvDfOZN6M/JtF6buXEyD934vrb8WhsX1/FuM7OtoMlQyIqAadnE8FqqDL4KDPiEfCQH6pQcQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-generic-pool@0.47.0": + resolution: + { + integrity: sha512-UfHqf3zYK+CwDwEtTjaD12uUqGGTswZ7ofLBEdQ4sEJp9GHSSJMQ2hT3pgBxyKADzUdoxQAv/7NqvL42ZI+Qbw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-graphql@0.51.0": + resolution: + { + integrity: sha512-LchkOu9X5DrXAnPI1+Z06h/EH/zC7D6sA86hhPrk3evLlsJTz0grPrkL/yUJM9Ty0CL/y2HSvmWQCjbJEz/ADg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-hapi@0.50.0": + resolution: + { + integrity: sha512-5xGusXOFQXKacrZmDbpHQzqYD1gIkrMWuwvlrEPkYOsjUqGUjl1HbxCsn5Y9bUXOCgP1Lj6A4PcKt1UiJ2MujA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-http@0.203.0": + resolution: + { + integrity: sha512-y3uQAcCOAwnO6vEuNVocmpVzG3PER6/YZqbPbbffDdJ9te5NkHEkfSMNzlC3+v7KlE+WinPGc3N7MR30G1HY2g==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-ioredis@0.52.0": + resolution: + { + integrity: sha512-rUvlyZwI90HRQPYicxpDGhT8setMrlHKokCtBtZgYxQWRF5RBbG4q0pGtbZvd7kyseuHbFpA3I/5z7M8b/5ywg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-kafkajs@0.13.0": + resolution: + { + integrity: sha512-FPQyJsREOaGH64hcxlzTsIEQC4DYANgTwHjiB7z9lldmvua1LRMVn3/FfBlzXoqF179B0VGYviz6rn75E9wsDw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-knex@0.48.0": + resolution: + { + integrity: sha512-V5wuaBPv/lwGxuHjC6Na2JFRjtPgstw19jTFl1B1b6zvaX8zVDYUDaR5hL7glnQtUSCMktPttQsgK4dhXpddcA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-koa@0.51.0": + resolution: + { + integrity: sha512-XNLWeMTMG1/EkQBbgPYzCeBD0cwOrfnn8ao4hWgLv0fNCFQu1kCsJYygz2cvKuCs340RlnG4i321hX7R8gj3Rg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-lru-memoizer@0.48.0": + resolution: + { + integrity: sha512-KUW29wfMlTPX1wFz+NNrmE7IzN7NWZDrmFWHM/VJcmFEuQGnnBuTIdsP55CnBDxKgQ/qqYFp4udQFNtjeFosPw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-mongodb@0.56.0": + resolution: + { + integrity: sha512-YG5IXUUmxX3Md2buVMvxm9NWlKADrnavI36hbJsihqqvBGsWnIfguf0rUP5Srr0pfPqhQjUP+agLMsvu0GmUpA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-mongoose@0.50.0": + resolution: + { + integrity: sha512-Am8pk1Ct951r4qCiqkBcGmPIgGhoDiFcRtqPSLbJrUZqEPUsigjtMjoWDRLG1Ki1NHgOF7D0H7d+suWz1AAizw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-mysql2@0.50.0": + resolution: + { + integrity: sha512-PoOMpmq73rOIE3nlTNLf3B1SyNYGsp7QXHYKmeTZZnJ2Ou7/fdURuOhWOI0e6QZ5gSem18IR1sJi6GOULBQJ9g==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-mysql@0.49.0": + resolution: + { + integrity: sha512-QU9IUNqNsrlfE3dJkZnFHqLjlndiU39ll/YAAEvWE40sGOCi9AtOF6rmEGzJ1IswoZ3oyePV7q2MP8SrhJfVAA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-pg@0.55.0": + resolution: + { + integrity: sha512-yfJ5bYE7CnkW/uNsnrwouG/FR7nmg09zdk2MSs7k0ZOMkDDAE3WBGpVFFApGgNu2U+gtzLgEzOQG4I/X+60hXw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-redis@0.51.0": + resolution: + { + integrity: sha512-uL/GtBA0u72YPPehwOvthAe+Wf8k3T+XQPBssJmTYl6fzuZjNq8zTfxVFhl9nRFjFVEe+CtiYNT0Q3AyqW1Z0A==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-tedious@0.22.0": + resolution: + { + integrity: sha512-XrrNSUCyEjH1ax9t+Uo6lv0S2FCCykcF7hSxBMxKf7Xn0bPRxD3KyFUZy25aQXzbbbUHhtdxj3r2h88SfEM3aA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation-undici@0.14.0": + resolution: + { + integrity: sha512-2HN+7ztxAReXuxzrtA3WboAKlfP5OsPA57KQn2AdYZbJ3zeRPcLXyW4uO/jpLE6PLm0QRtmeGCmfYpqRlwgSwg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.7.0 + + "@opentelemetry/instrumentation@0.203.0": + resolution: + { + integrity: sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation@0.204.0": + resolution: + { + integrity: sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/instrumentation@0.57.2": + resolution: + { + integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==, + } + engines: { node: ">=14" } + peerDependencies: + "@opentelemetry/api": ^1.3.0 + + "@opentelemetry/redis-common@0.38.0": + resolution: + { + integrity: sha512-4Wc0AWURII2cfXVVoZ6vDqK+s5n4K5IssdrlVrvGsx6OEOKdghKtJZqXAHWFiZv4nTDLH2/2fldjIHY8clMOjQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + + "@opentelemetry/resources@2.1.0": + resolution: + { + integrity: sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ">=1.3.0 <1.10.0" + + "@opentelemetry/sdk-trace-base@2.1.0": + resolution: + { + integrity: sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ">=1.3.0 <1.10.0" + + "@opentelemetry/semantic-conventions@1.37.0": + resolution: + { + integrity: sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==, + } + engines: { node: ">=14" } + + "@opentelemetry/sql-common@0.41.0": + resolution: + { + integrity: sha512-pmzXctVbEERbqSfiAgdes9Y63xjoOyXcD7B6IXBkVb+vbM7M9U98mn33nGXxPf4dfYR0M+vhcKRZmbSJ7HfqFA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + "@opentelemetry/api": ^1.1.0 + "@pandacss/is-valid-prop@0.54.0": resolution: { @@ -914,6 +1760,21 @@ packages: } engines: { node: ">=14" } + "@pkgr/core@0.2.9": + resolution: + { + integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==, + } + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } + + "@prisma/instrumentation@6.14.0": + resolution: + { + integrity: sha512-Po/Hry5bAeunRDq0yAQueKookW3glpP+qjjvvyOfm6dI2KG5/Y6Bgg3ahyWd7B0u2E+Wf9xRk2rtdda7ySgK1A==, + } + peerDependencies: + "@opentelemetry/api": ^1.8 + "@radix-ui/primitive@1.1.3": resolution: { @@ -1198,6 +2059,25 @@ packages: integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==, } + "@redocly/ajv@8.11.3": + resolution: + { + integrity: sha512-4P3iZse91TkBiY+Dx5DUgxQ9GXkVJf++cmI0MOyLDxV9b5MUBI4II6ES8zA5JCbO72nKAJxWrw4PUPW+YP3ZDQ==, + } + + "@redocly/config@0.22.2": + resolution: + { + integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==, + } + + "@redocly/openapi-core@1.34.5": + resolution: + { + integrity: sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==, + } + engines: { node: ">=18.17.0", npm: ">=9.5.0" } + "@reduxjs/toolkit@2.8.2": resolution: { @@ -1212,25 +2092,18 @@ packages: react-redux: optional: true - "@rollup/plugin-commonjs@24.0.0": + "@rollup/plugin-commonjs@28.0.1": resolution: { - integrity: sha512-0w0wyykzdyRRPHOb0cQt14mIBLujfAv6GgP6g8nvg/iBxEm112t3YPPq+Buqe2+imvElTka+bjNlJ/gB56TD8g==, + integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==, } - engines: { node: ">=14.0.0" } + engines: { node: ">=16.0.0 || 14 >= 14.17" } peerDependencies: - rollup: ^2.68.0||^3.0.0 + rollup: ^2.68.0||^3.0.0||^4.0.0 peerDependenciesMeta: rollup: optional: true - "@rollup/pluginutils@4.2.1": - resolution: - { - integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==, - } - engines: { node: ">= 8.0.0" } - "@rollup/pluginutils@5.2.0": resolution: { @@ -1243,6 +2116,174 @@ packages: rollup: optional: true + "@rollup/rollup-android-arm-eabi@4.50.1": + resolution: + { + integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==, + } + cpu: [arm] + os: [android] + + "@rollup/rollup-android-arm64@4.50.1": + resolution: + { + integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==, + } + cpu: [arm64] + os: [android] + + "@rollup/rollup-darwin-arm64@4.50.1": + resolution: + { + integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==, + } + cpu: [arm64] + os: [darwin] + + "@rollup/rollup-darwin-x64@4.50.1": + resolution: + { + integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==, + } + cpu: [x64] + os: [darwin] + + "@rollup/rollup-freebsd-arm64@4.50.1": + resolution: + { + integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==, + } + cpu: [arm64] + os: [freebsd] + + "@rollup/rollup-freebsd-x64@4.50.1": + resolution: + { + integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==, + } + cpu: [x64] + os: [freebsd] + + "@rollup/rollup-linux-arm-gnueabihf@4.50.1": + resolution: + { + integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==, + } + cpu: [arm] + os: [linux] + + "@rollup/rollup-linux-arm-musleabihf@4.50.1": + resolution: + { + integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==, + } + cpu: [arm] + os: [linux] + + "@rollup/rollup-linux-arm64-gnu@4.50.1": + resolution: + { + integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==, + } + cpu: [arm64] + os: [linux] + + "@rollup/rollup-linux-arm64-musl@4.50.1": + resolution: + { + integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==, + } + cpu: [arm64] + os: [linux] + + "@rollup/rollup-linux-loongarch64-gnu@4.50.1": + resolution: + { + integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==, + } + cpu: [loong64] + os: [linux] + + "@rollup/rollup-linux-ppc64-gnu@4.50.1": + resolution: + { + integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==, + } + cpu: [ppc64] + os: [linux] + + "@rollup/rollup-linux-riscv64-gnu@4.50.1": + resolution: + { + integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==, + } + cpu: [riscv64] + os: [linux] + + "@rollup/rollup-linux-riscv64-musl@4.50.1": + resolution: + { + integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==, + } + cpu: [riscv64] + os: [linux] + + "@rollup/rollup-linux-s390x-gnu@4.50.1": + resolution: + { + integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==, + } + cpu: [s390x] + os: [linux] + + "@rollup/rollup-linux-x64-gnu@4.50.1": + resolution: + { + integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==, + } + cpu: [x64] + os: [linux] + + "@rollup/rollup-linux-x64-musl@4.50.1": + resolution: + { + integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==, + } + cpu: [x64] + os: [linux] + + "@rollup/rollup-openharmony-arm64@4.50.1": + resolution: + { + integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==, + } + cpu: [arm64] + os: [openharmony] + + "@rollup/rollup-win32-arm64-msvc@4.50.1": + resolution: + { + integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==, + } + cpu: [arm64] + os: [win32] + + "@rollup/rollup-win32-ia32-msvc@4.50.1": + resolution: + { + integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==, + } + cpu: [ia32] + os: [win32] + + "@rollup/rollup-win32-x64-msvc@4.50.1": + resolution: + { + integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==, + } + cpu: [x64] + os: [win32] + "@rtsao/scc@1.1.0": resolution: { @@ -1255,126 +2296,251 @@ packages: integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==, } - "@sentry-internal/feedback@7.120.4": + "@sentry-internal/browser-utils@10.11.0": resolution: { - integrity: sha512-eSwgvTdrh03zYYaI6UVOjI9p4VmKg6+c2+CBQfRZX++6wwnCVsNv7XF7WUIpVGBAkJ0N2oapjQmCzJKGKBRWQg==, + integrity: sha512-fnMlz5ntap6x4vRsLOHwPqXh7t82StgAiRt+EaqcMX0t9l8C0w0df8qwrONKXvE5GdHWTNFJj5qR15FERSkg3Q==, } - engines: { node: ">=12" } + engines: { node: ">=18" } - "@sentry-internal/replay-canvas@7.120.4": + "@sentry-internal/browser-utils@8.55.0": resolution: { - integrity: sha512-2+W4CgUL1VzrPjArbTid4WhKh7HH21vREVilZdvffQPVwOEpgNTPAb69loQuTlhJVveh9hWTj2nE5UXLbLP+AA==, + integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==, } - engines: { node: ">=12" } + engines: { node: ">=14.18" } - "@sentry-internal/tracing@7.120.4": + "@sentry-internal/feedback@10.11.0": resolution: { - integrity: sha512-Fz5+4XCg3akeoFK+K7g+d7HqGMjmnLoY2eJlpONJmaeT9pXY7yfUyXKZMmMajdE2LxxKJgQ2YKvSCaGVamTjHw==, + integrity: sha512-ADey51IIaa29kepb8B7aSgSGSrcyT7QZdRsN1rhitefzrruHzpSUci5c2EPIvmWfKJq8Wnvukm9BHXZXAAIOzA==, } - engines: { node: ">=8" } + engines: { node: ">=18" } - "@sentry/browser@7.120.4": + "@sentry-internal/feedback@8.55.0": resolution: { - integrity: sha512-ymlNtIPG6HAKzM/JXpWVGCzCNufZNADfy+O/olZuVJW5Be1DtOFyRnBvz0LeKbmxJbXb2lX/XMhuen6PXPdoQw==, + integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==, } - engines: { node: ">=8" } + engines: { node: ">=14.18" } - "@sentry/cli@1.77.3": + "@sentry-internal/replay-canvas@10.11.0": resolution: { - integrity: sha512-c3eDqcDRmy4TFz2bFU5Y6QatlpoBPPa8cxBooaS4aMQpnIdLYPF1xhyyiW0LQlDUNc3rRjNF7oN5qKoaRoMTQQ==, + integrity: sha512-brWQ90IYQyZr44IpTprlmvbtz4l2ABzLdpP94Egh12Onf/q6n4CjLKaA25N5kX0uggHqX1Rs7dNaG0mP3ETHhA==, } - engines: { node: ">= 8" } + engines: { node: ">=18" } + + "@sentry-internal/replay-canvas@8.55.0": + resolution: + { + integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==, + } + engines: { node: ">=14.18" } + + "@sentry-internal/replay@10.11.0": + resolution: + { + integrity: sha512-t4M2bxMp2rKGK/l7bkVWjN+xVw9H9V12jAeXmO/Fskz2RcG1ZNLQnKSx/W/zCRMk8k7xOQFsfiApq+zDN+ziKA==, + } + engines: { node: ">=18" } + + "@sentry-internal/replay@8.55.0": + resolution: + { + integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==, + } + engines: { node: ">=14.18" } + + "@sentry/babel-plugin-component-annotate@4.3.0": + resolution: + { + integrity: sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==, + } + engines: { node: ">= 14" } + + "@sentry/browser@10.11.0": + resolution: + { + integrity: sha512-qemaKCJKJHHCyGBpdLq23xL5u9Xvir20XN7YFTnHcEq4Jvj0GoWsslxKi5cQB2JvpYn62WxTiDgVLeQlleZhSg==, + } + engines: { node: ">=18" } + + "@sentry/browser@8.55.0": + resolution: + { + integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==, + } + engines: { node: ">=14.18" } + + "@sentry/bundler-plugin-core@4.3.0": + resolution: + { + integrity: sha512-dmR4DJhJ4jqVWGWppuTL2blNFqOZZnt4aLkewbD1myFG3KVfUx8CrMQWEmGjkgPOtj5TO6xH9PyTJjXC6o5tnA==, + } + engines: { node: ">= 14" } + + "@sentry/cli-darwin@2.53.0": + resolution: + { + integrity: sha512-NNPfpILMwKgpHiyJubHHuauMKltkrgLQ5tvMdxNpxY60jBNdo5VJtpESp4XmXlnidzV4j1z61V4ozU6ttDgt5Q==, + } + engines: { node: ">=10" } + os: [darwin] + + "@sentry/cli-linux-arm64@2.53.0": + resolution: + { + integrity: sha512-xY/CZ1dVazsSCvTXzKpAgXaRqfljVfdrFaYZRUaRPf1ZJRGa3dcrivoOhSIeG/p5NdYtMvslMPY9Gm2MT0M83A==, + } + engines: { node: ">=10" } + cpu: [arm64] + os: [linux, freebsd, android] + + "@sentry/cli-linux-arm@2.53.0": + resolution: + { + integrity: sha512-NdRzQ15Ht83qG0/Lyu11ciy/Hu/oXbbtJUgwzACc7bWvHQA8xEwTsehWexqn1529Kfc5EjuZ0Wmj3MHmp+jOWw==, + } + engines: { node: ">=10" } + cpu: [arm] + os: [linux, freebsd, android] + + "@sentry/cli-linux-i686@2.53.0": + resolution: + { + integrity: sha512-0REmBibGAB4jtqt9S6JEsFF4QybzcXHPcHtJjgMi5T0ueh952uG9wLzjSxQErCsxTKF+fL8oG0Oz5yKBuCwCCQ==, + } + engines: { node: ">=10" } + cpu: [x86, ia32] + os: [linux, freebsd, android] + + "@sentry/cli-linux-x64@2.53.0": + resolution: + { + integrity: sha512-9UGJL+Vy5N/YL1EWPZ/dyXLkShlNaDNrzxx4G7mTS9ywjg+BIuemo6rnN7w43K1NOjObTVO6zY0FwumJ1pCyLg==, + } + engines: { node: ">=10" } + cpu: [x64] + os: [linux, freebsd, android] + + "@sentry/cli-win32-arm64@2.53.0": + resolution: + { + integrity: sha512-G1kjOjrjMBY20rQcJV2GA8KQE74ufmROCDb2GXYRfjvb1fKAsm4Oh8N5+Tqi7xEHdjQoLPkE4CNW0aH68JSUDQ==, + } + engines: { node: ">=10" } + cpu: [arm64] + os: [win32] + + "@sentry/cli-win32-i686@2.53.0": + resolution: + { + integrity: sha512-qbGTZUzesuUaPtY9rPXdNfwLqOZKXrJRC1zUFn52hdo6B+Dmv0m/AHwRVFHZP53Tg1NCa8bDei2K/uzRN0dUZw==, + } + engines: { node: ">=10" } + cpu: [x86, ia32] + os: [win32] + + "@sentry/cli-win32-x64@2.53.0": + resolution: + { + integrity: sha512-1TXYxYHtwgUq5KAJt3erRzzUtPqg7BlH9T7MdSPHjJatkrr/kwZqnVe2H6Arr/5NH891vOlIeSPHBdgJUAD69g==, + } + engines: { node: ">=10" } + cpu: [x64] + os: [win32] + + "@sentry/cli@2.53.0": + resolution: + { + integrity: sha512-n2ZNb+5Z6AZKQSI0SusQ7ZzFL637mfw3Xh4C3PEyVSn9LiF683fX0TTq8OeGmNZQS4maYfS95IFD+XpydU0dEA==, + } + engines: { node: ">= 10" } hasBin: true - "@sentry/core@7.120.4": + "@sentry/core@10.11.0": resolution: { - integrity: sha512-TXu3Q5kKiq8db9OXGkWyXUbIxMMuttB5vJ031yolOl5T/B69JRyAoKuojLBjRv1XX583gS1rSSoX8YXX7ATFGA==, + integrity: sha512-39Rxn8cDXConx3+SKOCAhW+/hklM7UDaz+U1OFzFMDlT59vXSpfI6bcXtNiFDrbOxlQ2hX8yAqx8YRltgSftoA==, } - engines: { node: ">=8" } + engines: { node: ">=18" } - "@sentry/integrations@7.120.4": + "@sentry/core@8.55.0": resolution: { - integrity: sha512-kkBTLk053XlhDCg7OkBQTIMF4puqFibeRO3E3YiVc4PGLnocXMaVpOSCkMqAc1k1kZ09UgGi8DxfQhnFEjUkpA==, + integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==, } - engines: { node: ">=8" } + engines: { node: ">=14.18" } - "@sentry/nextjs@7.120.4": + "@sentry/nextjs@10.11.0": resolution: { - integrity: sha512-1wtyDP1uiVvYqaJyCgXfP69eqyDgJrd6lERAVd4WqXNVEIs4vBT8oxfPQz6gxG2SJJUiTyQRjubMxuEc7dPoGQ==, + integrity: sha512-oMRmRW982H6kNlUHNij5QAro8Kbi43r3VrcrKtrx7LgjHOUTFUvZmJeynC+T+PcMgLhQNvCC3JgzOhfSqxOChg==, } - engines: { node: ">=8" } + engines: { node: ">=18" } peerDependencies: - next: ^10.0.8 || ^11.0 || ^12.0 || ^13.0 || ^14.0 - react: 16.x || 17.x || 18.x - webpack: ">= 4.0.0" - peerDependenciesMeta: - webpack: - optional: true + next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0 - "@sentry/node@7.120.4": + "@sentry/node-core@10.11.0": resolution: { - integrity: sha512-qq3wZAXXj2SRWhqErnGCSJKUhPSlZ+RGnCZjhfjHpP49KNpcd9YdPTIUsFMgeyjdh6Ew6aVCv23g1hTP0CHpYw==, + integrity: sha512-dkVZ06F+W5W0CsD47ATTTOTTocmccT/ezrF9idspQq+HVOcjoKSU60WpWo22NjtVNdSYKLnom0q1LKRoaRA/Ww==, } - engines: { node: ">=8" } - - "@sentry/react@7.120.4": - resolution: - { - integrity: sha512-Pj1MSezEncE+5riuwsk8peMncuz5HR72Yr1/RdZhMZvUxoxAR/tkwD3aPcK6ddQJTagd2TGwhdr9SHuDLtONew==, - } - engines: { node: ">=8" } + engines: { node: ">=18" } peerDependencies: - react: 15.x || 16.x || 17.x || 18.x + "@opentelemetry/api": ^1.9.0 + "@opentelemetry/context-async-hooks": ^1.30.1 || ^2.0.0 + "@opentelemetry/core": ^1.30.1 || ^2.0.0 + "@opentelemetry/instrumentation": ">=0.57.1 <1" + "@opentelemetry/resources": ^1.30.1 || ^2.0.0 + "@opentelemetry/sdk-trace-base": ^1.30.1 || ^2.0.0 + "@opentelemetry/semantic-conventions": ^1.34.0 - "@sentry/replay@7.120.4": + "@sentry/node@10.11.0": resolution: { - integrity: sha512-FW8sPenNFfnO/K7sncsSTX4rIVak9j7VUiLIagJrcqZIC7d1dInFNjy8CdVJUlyz3Y3TOgIl3L3+ZpjfyMnaZg==, + integrity: sha512-Tbcjr3iQAEjYi7/QIpdS8afv/LU1TwDTiy5x87MSpVEoeFcZ7f2iFC4GV0fhB3p4qDuFdL2JGVsIIrzapp8Y4A==, } - engines: { node: ">=12" } + engines: { node: ">=18" } - "@sentry/types@7.120.4": + "@sentry/opentelemetry@10.11.0": resolution: { - integrity: sha512-cUq2hSSe6/qrU6oZsEP4InMI5VVdD86aypE+ENrQ6eZEVLTCYm1w6XhW1NvIu3UuWh7gZec4a9J7AFpYxki88Q==, + integrity: sha512-BY2SsVlRKICzNUO9atUy064BZqYnhV5A/O+JjEx0kj7ylq+oZd++zmGkks00rSwaJE220cVcVhpwqxcFUpc2hw==, } - engines: { node: ">=8" } + engines: { node: ">=18" } + peerDependencies: + "@opentelemetry/api": ^1.9.0 + "@opentelemetry/context-async-hooks": ^1.30.1 || ^2.0.0 + "@opentelemetry/core": ^1.30.1 || ^2.0.0 + "@opentelemetry/sdk-trace-base": ^1.30.1 || ^2.0.0 + "@opentelemetry/semantic-conventions": ^1.34.0 - "@sentry/utils@7.120.4": + "@sentry/react@10.11.0": resolution: { - integrity: sha512-zCKpyDIWKHwtervNK2ZlaK8mMV7gVUijAgFeJStH+CU/imcdquizV3pFLlSQYRswG+Lbyd6CT/LGRh3IbtkCFw==, + integrity: sha512-bE4lJ5Ni/n9JUdLWGG99yucY0/zOUXjKl9gfSTkvUvOiAIX/bY0Y4WgOqeWySvbMz679ZdOwF34k8RA/gI7a8g==, } - engines: { node: ">=8" } + engines: { node: ">=18" } + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x - "@sentry/vercel-edge@7.120.4": + "@sentry/vercel-edge@10.11.0": resolution: { - integrity: sha512-wZMnF7Rt2IBfStQTVDhjShEtLcsH1WNc7YVgzoibuIeRDrEmyx/MFIsru2BkhWnz7m0TRnWXxA40cH+6VZsf5w==, + integrity: sha512-jAsJ8RbbF2JWj2wnXfd6BwWxCR6GBITMtlaoWc7pG22HknEtoH15dKsQC3Ew5r/KRcofr2e+ywdnBn5CPr1Pbg==, } - engines: { node: ">=8" } + engines: { node: ">=18" } - "@sentry/webpack-plugin@1.21.0": + "@sentry/webpack-plugin@4.3.0": resolution: { - integrity: sha512-x0PYIMWcsTauqxgl7vWUY6sANl+XGKtx7DCVnnY7aOIIlIna0jChTAPANTfA2QrK+VK+4I/4JxatCEZBnXh3Og==, - } - engines: { node: ">= 8" } - - "@sinclair/typebox@0.25.24": - resolution: - { - integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==, + integrity: sha512-K4nU1SheK/tvyakBws2zfd+MN6hzmpW+wPTbSbDWn1+WL9+g9hsPh8hjFFiVe47AhhUoUZ3YgiH2HyeHXjHflA==, } + engines: { node: ">= 14" } + peerDependencies: + webpack: ">=4.40.0" "@sinclair/typebox@0.27.8": resolution: @@ -1382,6 +2548,24 @@ packages: integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==, } + "@sinclair/typebox@0.34.41": + resolution: + { + integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==, + } + + "@sinonjs/commons@3.0.1": + resolution: + { + integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==, + } + + "@sinonjs/fake-timers@13.0.5": + resolution: + { + integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==, + } + "@socket.io/component-emitter@3.1.2": resolution: { @@ -1400,10 +2584,10 @@ packages: integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==, } - "@swc/counter@0.1.3": + "@swc/helpers@0.5.15": resolution: { - integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==, + integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==, } "@swc/helpers@0.5.17": @@ -1412,24 +2596,19 @@ packages: integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==, } - "@swc/helpers@0.5.5": + "@tanstack/query-core@5.85.9": resolution: { - integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==, + integrity: sha512-5fxb9vwyftYE6KFLhhhDyLr8NO75+Wpu7pmTo+TkwKmMX2oxZDoLwcqGP8ItKSpUMwk3urWgQDZfyWr5Jm9LsQ==, } - "@tootallnate/once@2.0.0": + "@tanstack/react-query@5.85.9": resolution: { - integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==, - } - engines: { node: ">= 10" } - - "@ts-morph/common@0.11.1": - resolution: - { - integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==, + integrity: sha512-2T5zgSpcOZXGkH/UObIbIkGmUPQqZqn7esVQFXLOze622h4spgWf5jmvrqAo9dnI13/hyMcNsF1jsoDcb59nJQ==, } + peerDependencies: + react: ^18 || ^19 "@tsconfig/node10@1.0.11": resolution: @@ -1461,12 +2640,54 @@ packages: integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==, } + "@types/babel__core@7.20.5": + resolution: + { + integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, + } + + "@types/babel__generator@7.27.0": + resolution: + { + integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==, + } + + "@types/babel__template@7.4.4": + resolution: + { + integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==, + } + + "@types/babel__traverse@7.28.0": + resolution: + { + integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, + } + + "@types/connect@3.4.38": + resolution: + { + integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==, + } + "@types/debug@4.1.12": resolution: { integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==, } + "@types/eslint-scope@3.7.7": + resolution: + { + integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==, + } + + "@types/eslint@9.6.1": + resolution: + { + integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==, + } + "@types/estree-jsx@1.0.5": resolution: { @@ -1491,6 +2712,13 @@ packages: integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==, } + "@types/ioredis@5.0.0": + resolution: + { + integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==, + } + deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed. + "@types/istanbul-lib-coverage@2.0.6": resolution: { @@ -1509,6 +2737,12 @@ packages: integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==, } + "@types/jest@30.0.0": + resolution: + { + integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==, + } + "@types/json-schema@7.0.15": resolution: { @@ -1533,30 +2767,48 @@ packages: integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==, } + "@types/mysql@2.15.27": + resolution: + { + integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==, + } + "@types/node-fetch@2.6.13": resolution: { integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==, } - "@types/node@16.18.11": - resolution: - { - integrity: sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==, - } - "@types/node@24.2.1": resolution: { integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==, } + "@types/node@24.3.1": + resolution: + { + integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==, + } + "@types/parse-json@4.0.2": resolution: { integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==, } + "@types/pg-pool@2.0.6": + resolution: + { + integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==, + } + + "@types/pg@8.15.4": + resolution: + { + integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==, + } + "@types/prop-types@15.7.15": resolution: { @@ -1575,6 +2827,24 @@ packages: integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==, } + "@types/shimmer@1.2.0": + resolution: + { + integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==, + } + + "@types/stack-utils@2.0.3": + resolution: + { + integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==, + } + + "@types/tedious@4.0.14": + resolution: + { + integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==, + } + "@types/ua-parser-js@0.7.39": resolution: { @@ -1888,135 +3158,94 @@ packages: cpu: [x64] os: [win32] - "@upstash/redis@1.35.3": + "@webassemblyjs/ast@1.14.1": resolution: { - integrity: sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w==, + integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==, } - "@vercel/build-utils@8.4.12": + "@webassemblyjs/floating-point-hex-parser@1.13.2": resolution: { - integrity: sha512-pIH0b965wJhd1otROVPndfZenPKFVoYSaRjtSKVOT/oNBT13ifq86UVjb5ZjoVfqUI2TtSTP+68kBqLPeoq30g==, + integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==, } - "@vercel/edge-config-fs@0.1.0": + "@webassemblyjs/helper-api-error@1.13.2": resolution: { - integrity: sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg==, + integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==, } - "@vercel/edge-config@0.4.1": + "@webassemblyjs/helper-buffer@1.14.1": resolution: { - integrity: sha512-4Mc3H7lE+x4RrL17nY8CWeEorvJHbkNbQTy9p8H1tO7y11WeKj5xeZSr07wNgfWInKXDUwj5FZ3qd/jIzjPxug==, - } - engines: { node: ">=14.6" } - - "@vercel/error-utils@2.0.2": - resolution: - { - integrity: sha512-Sj0LFafGpYr6pfCqrQ82X6ukRl5qpmVrHM/191kNYFqkkB9YkjlMAj6QcEsvCG259x4QZ7Tya++0AB85NDPbKQ==, + integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==, } - "@vercel/fun@1.1.0": + "@webassemblyjs/helper-numbers@1.13.2": resolution: { - integrity: sha512-SpuPAo+MlAYMtcMcC0plx7Tv4Mp7SQhJJj1iIENlOnABL24kxHpL09XLQMGzZIzIW7upR8c3edwgfpRtp+dhVw==, - } - engines: { node: ">= 10" } - - "@vercel/gatsby-plugin-vercel-analytics@1.0.11": - resolution: - { - integrity: sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw==, + integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==, } - "@vercel/gatsby-plugin-vercel-builder@2.0.56": + "@webassemblyjs/helper-wasm-bytecode@1.13.2": resolution: { - integrity: sha512-SZM8k/YcOcfk2p1cSZOuSK37CDBJtF/WiEr8CemDI/MBbXM4aC2StfzDd0F0cK/2rExpSA9lTAE9ia3w+cDS9w==, + integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==, } - "@vercel/go@3.2.0": + "@webassemblyjs/helper-wasm-section@1.14.1": resolution: { - integrity: sha512-zUCBoh57x1OEtw+TKdRhSQciqERrpDxLlPeBOYawUCC5uKjsBjhdq0U21+NGz2LcRUaYyYYGMw6BzqVaig9u1g==, + integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==, } - "@vercel/hydrogen@1.0.9": + "@webassemblyjs/ieee754@1.13.2": resolution: { - integrity: sha512-IPAVaALuGAzt2apvTtBs5tB+8zZRzn/yG3AGp8dFyCsw/v5YOuk0Q5s8Z3fayLvJbFpjrKtqRNDZzVJBBU3MrQ==, + integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==, } - "@vercel/kv@2.0.0": + "@webassemblyjs/leb128@1.13.2": resolution: { - integrity: sha512-zdVrhbzZBYo5d1Hfn4bKtqCeKf0FuzW8rSHauzQVMUgv1+1JOwof2mWcBuI+YMJy8s0G0oqAUfQ7HgUDzb8EbA==, - } - engines: { node: ">=14.6" } - - "@vercel/next@4.3.18": - resolution: - { - integrity: sha512-ih6++AA7/NCcLkMpdsDhr/folMlAKsU1sYUoyOjq4rYE9sSapELtgxls0CArv4ehE2Tt4YwoxBISnKPZKK5SSA==, + integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==, } - "@vercel/nft@0.27.3": + "@webassemblyjs/utf8@1.13.2": resolution: { - integrity: sha512-oySTdDSzUAFDXpsSLk9Q943o+/Yu/+TCFxnehpFQEf/3khi2stMpTHPVNwFdvZq/Z4Ky93lE+MGHpXCRpMkSCA==, - } - engines: { node: ">=16" } - hasBin: true - - "@vercel/node@3.2.24": - resolution: - { - integrity: sha512-KEm50YBmcfRNOw5NfdcqMI4BkP4+5TD9kRwAByHHlIZXLj1NTTknvMF+69sHBYzwpK/SUZIkeo7jTrtcl4g+RQ==, + integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==, } - "@vercel/python@4.3.1": + "@webassemblyjs/wasm-edit@1.14.1": resolution: { - integrity: sha512-pWRApBwUsAQJS8oZ7eKMiaBGbYJO71qw2CZqDFvkTj34FNBZtNIUcWSmqGfJJY5m2pU/9wt8z1CnKIyT9dstog==, + integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==, } - "@vercel/redwood@2.1.8": + "@webassemblyjs/wasm-gen@1.14.1": resolution: { - integrity: sha512-qBUBqIDxPEYnxRh3tsvTaPMtBkyK/D2tt9tBugNPe0OeYnMCMXVj9SJYbxiDI2GzAEFUZn4Poh63CZtXMDb9Tg==, + integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==, } - "@vercel/remix-builder@2.2.13": + "@webassemblyjs/wasm-opt@1.14.1": resolution: { - integrity: sha512-TenVtvfERodSwUjm0rzjz3v00Drd0FUXLWnwdwnv7VLgqmX2FW/2+1byhmPhJicMp3Eybl52GvF2/KbBkNo95w==, + integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==, } - "@vercel/routing-utils@3.1.0": + "@webassemblyjs/wasm-parser@1.14.1": resolution: { - integrity: sha512-Ci5xTjVTJY/JLZXpCXpLehMft97i9fH34nu9PGav6DtwkVUF6TOPX86U0W0niQjMZ5n6/ZP0BwcJK2LOozKaGw==, + integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==, } - "@vercel/ruby@2.1.0": + "@webassemblyjs/wast-printer@1.14.1": resolution: { - integrity: sha512-UZYwlSEEfVnfzTmgkD+kxex9/gkZGt7unOWNyWFN7V/ZnZSsGBUgv6hXLnwejdRi3EztgRQEBd1kUKlXdIeC0Q==, - } - - "@vercel/static-build@2.5.34": - resolution: - { - integrity: sha512-4RL60ghhBufs/45j6J9zQzMpt8JmUhp/4+xE8RxO80n6qTlc/oERKrWxzeXLEGF32whSHsB+ROJt0Ytytoz2Tw==, - } - - "@vercel/static-config@3.0.0": - resolution: - { - integrity: sha512-2qtvcBJ1bGY0dYGYh3iM7yGKkk971FujLEDXzuW5wcZsPr1GSEjO/w2iSr3qve6nDDtBImsGoDEnus5FI4+fIw==, + integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==, } "@whereby.com/browser-sdk@3.13.1": @@ -2041,6 +3270,18 @@ packages: } engines: { node: ">=16.0.0" } + "@xtuc/ieee754@1.2.0": + resolution: + { + integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==, + } + + "@xtuc/long@4.2.2": + resolution: + { + integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==, + } + "@zag-js/accordion@1.21.0": resolution: { @@ -2458,12 +3699,6 @@ packages: integrity: sha512-yI/CZizbk387TdkDCy9Uc4l53uaeQuWAIJESrmAwwq6yMNbHZ2dm5+1NHdZr/guES5TgyJa/BYJsNJeCsCfesg==, } - abbrev@1.1.1: - resolution: - { - integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==, - } - acorn-import-attributes@1.9.5: resolution: { @@ -2472,6 +3707,15 @@ packages: peerDependencies: acorn: ^8 + acorn-import-phases@1.0.4: + resolution: + { + integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==, + } + engines: { node: ">=10.13.0" } + peerDependencies: + acorn: ^8.14.0 + acorn-jsx@5.3.2: resolution: { @@ -2502,18 +3746,58 @@ packages: } engines: { node: ">= 6.0.0" } + agent-base@7.1.4: + resolution: + { + integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==, + } + engines: { node: ">= 14" } + + ajv-formats@2.1.1: + resolution: + { + integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==, + } + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@5.1.0: + resolution: + { + integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==, + } + peerDependencies: + ajv: ^8.8.2 + ajv@6.12.6: resolution: { integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==, } - ajv@8.6.3: + ajv@8.17.1: resolution: { - integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==, + integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==, } + ansi-colors@4.1.3: + resolution: + { + integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==, + } + engines: { node: ">=6" } + + ansi-escapes@4.3.2: + resolution: + { + integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==, + } + engines: { node: ">=8" } + ansi-regex@5.0.1: resolution: { @@ -2535,6 +3819,13 @@ packages: } engines: { node: ">=8" } + ansi-styles@5.2.0: + resolution: + { + integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, + } + engines: { node: ">=10" } + ansi-styles@6.2.1: resolution: { @@ -2555,26 +3846,6 @@ packages: } engines: { node: ">= 8" } - aproba@2.1.0: - resolution: - { - integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==, - } - - are-we-there-yet@2.0.0: - resolution: - { - integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==, - } - engines: { node: ">=10" } - deprecated: This package is no longer supported. - - arg@4.1.0: - resolution: - { - integrity: sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==, - } - arg@4.1.3: resolution: { @@ -2587,6 +3858,12 @@ packages: integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==, } + argparse@1.0.10: + resolution: + { + integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==, + } + argparse@2.0.1: resolution: { @@ -2676,32 +3953,6 @@ packages: } engines: { node: ">= 0.4" } - async-listen@1.2.0: - resolution: - { - integrity: sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA==, - } - - async-listen@3.0.0: - resolution: - { - integrity: sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==, - } - engines: { node: ">= 14" } - - async-listen@3.0.1: - resolution: - { - integrity: sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==, - } - engines: { node: ">= 14" } - - async-sema@3.1.1: - resolution: - { - integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==, - } - asynckit@0.4.0: resolution: { @@ -2758,6 +4009,29 @@ packages: } engines: { node: ">= 0.4" } + babel-jest@30.1.2: + resolution: + { + integrity: sha512-IQCus1rt9kaSh7PQxLYRY5NmkNrNlU2TpabzwV7T2jljnpdHOcmnYYv8QmE04Li4S3a2Lj8/yXyET5pBarPr6g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + "@babel/core": ^7.11.0 + + babel-plugin-istanbul@7.0.0: + resolution: + { + integrity: sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==, + } + engines: { node: ">=12" } + + babel-plugin-jest-hoist@30.0.1: + resolution: + { + integrity: sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + babel-plugin-macros@3.1.0: resolution: { @@ -2765,6 +4039,23 @@ packages: } engines: { node: ">=10", npm: ">=6" } + babel-preset-current-node-syntax@1.2.0: + resolution: + { + integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==, + } + peerDependencies: + "@babel/core": ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.0.1: + resolution: + { + integrity: sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + "@babel/core": ^7.11.0 + bail@2.0.2: resolution: { @@ -2790,10 +4081,10 @@ packages: } engines: { node: ">=8" } - bindings@1.5.0: + bowser@2.12.1: resolution: { - integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==, + integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==, } brace-expansion@1.1.12: @@ -2823,6 +4114,19 @@ packages: engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true + bs-logger@0.2.6: + resolution: + { + integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==, + } + engines: { node: ">= 6" } + + bser@2.1.1: + resolution: + { + integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==, + } + btoa@1.2.1: resolution: { @@ -2831,10 +4135,10 @@ packages: engines: { node: ">= 0.4.0" } hasBin: true - buffer-crc32@0.2.13: + buffer-from@1.1.2: resolution: { - integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==, + integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, } buffer@6.0.3: @@ -2843,31 +4147,6 @@ packages: integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==, } - busboy@1.6.0: - resolution: - { - integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==, - } - engines: { node: ">=10.16.0" } - - bytes@3.1.0: - resolution: - { - integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==, - } - engines: { node: ">= 0.8" } - - c12@1.11.1: - resolution: - { - integrity: sha512-KDU0TvSvVdaYcQKQ6iPHATGz/7p/KiVjPg4vQrB6Jg/wX9R0yl5RZxWm9IoZqaIHD2+6PZd81+KMGwRr/lRIUg==, - } - peerDependencies: - magicast: ^0.3.4 - peerDependenciesMeta: - magicast: - optional: true - call-bind-apply-helpers@1.0.2: resolution: { @@ -2903,12 +4182,19 @@ packages: } engines: { node: ">= 6" } - camelcase@8.0.0: + camelcase@5.3.1: resolution: { - integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==, + integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==, } - engines: { node: ">=16" } + engines: { node: ">=6" } + + camelcase@6.3.0: + resolution: + { + integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==, + } + engines: { node: ">=10" } caniuse-lite@1.0.30001734: resolution: @@ -2936,6 +4222,19 @@ packages: } engines: { node: ">=10" } + change-case@5.4.4: + resolution: + { + integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==, + } + + char-regex@1.0.2: + resolution: + { + integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==, + } + engines: { node: ">=10" } + character-entities-html4@2.1.0: resolution: { @@ -2966,13 +4265,6 @@ packages: integrity: sha512-LuLBA6r4aS/4B7pvOqmT4Bi+GKnNNC/V18K0zDTRFjAxNeUzGsr0wmsOfFhFH7fGjwdx6GX6wyIQBkUhFox2Pw==, } - chokidar@3.3.1: - resolution: - { - integrity: sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==, - } - engines: { node: ">= 8.10.0" } - chokidar@3.6.0: resolution: { @@ -2987,18 +4279,12 @@ packages: } engines: { node: ">= 14.16.0" } - chownr@1.1.4: + chrome-trace-event@1.0.4: resolution: { - integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==, + integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==, } - - chownr@2.0.0: - resolution: - { - integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==, - } - engines: { node: ">=10" } + engines: { node: ">=6.0" } ci-info@3.9.0: resolution: @@ -3007,16 +4293,23 @@ packages: } engines: { node: ">=8" } - citty@0.1.6: + ci-info@4.3.0: resolution: { - integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==, + integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==, + } + engines: { node: ">=8" } + + cjs-module-lexer@1.4.3: + resolution: + { + integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==, } - cjs-module-lexer@1.2.3: + cjs-module-lexer@2.1.0: resolution: { - integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==, + integrity: sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==, } classnames@2.5.1: @@ -3031,6 +4324,13 @@ packages: integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==, } + cliui@8.0.1: + resolution: + { + integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==, + } + engines: { node: ">=12" } + clsx@2.1.1: resolution: { @@ -3045,10 +4345,17 @@ packages: } engines: { node: ">=0.10.0" } - code-block-writer@10.1.1: + co@4.6.0: resolution: { - integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==, + integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==, + } + engines: { iojs: ">= 1.0.0", node: ">= 0.12.0" } + + collect-v8-coverage@1.0.2: + resolution: + { + integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==, } color-convert@2.0.1: @@ -3064,12 +4371,24 @@ packages: integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, } - color-support@1.1.3: + color-string@1.9.1: resolution: { - integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==, + integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==, + } + + color@4.2.3: + resolution: + { + integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==, + } + engines: { node: ">=12.5.0" } + + colorette@1.4.0: + resolution: + { + integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==, } - hasBin: true combined-stream@1.0.8: resolution: @@ -3084,12 +4403,11 @@ packages: integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==, } - commander@12.1.0: + commander@2.20.3: resolution: { - integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==, + integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==, } - engines: { node: ">=18" } commander@4.1.1: resolution: @@ -3110,45 +4428,18 @@ packages: integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, } - confbox@0.1.8: - resolution: - { - integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==, - } - - consola@3.4.2: - resolution: - { - integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==, - } - engines: { node: ^14.18.0 || >=16.10.0 } - - console-control-strings@1.1.0: - resolution: - { - integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==, - } - - content-type@1.0.4: - resolution: - { - integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==, - } - engines: { node: ">= 0.6" } - - convert-hrtime@3.0.0: - resolution: - { - integrity: sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==, - } - engines: { node: ">=8" } - convert-source-map@1.9.0: resolution: { integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==, } + convert-source-map@2.0.0: + resolution: + { + integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, + } + cookie@0.7.2: resolution: { @@ -3228,18 +4519,6 @@ packages: supports-color: optional: true - debug@4.1.1: - resolution: - { - integrity: sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==, - } - deprecated: Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797) - peerDependencies: - supports-color: "*" - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.7: resolution: { @@ -3270,12 +4549,30 @@ packages: integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==, } + dedent@1.7.0: + resolution: + { + integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==, + } + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-is@0.1.4: resolution: { integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, } + deepmerge@4.3.1: + resolution: + { + integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==, + } + engines: { node: ">=0.10.0" } + define-data-property@1.1.4: resolution: { @@ -3290,12 +4587,6 @@ packages: } engines: { node: ">= 0.4" } - defu@6.1.4: - resolution: - { - integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==, - } - delayed-stream@1.0.0: resolution: { @@ -3303,12 +4594,6 @@ packages: } engines: { node: ">=0.4.0" } - delegates@1.0.0: - resolution: - { - integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==, - } - denque@2.1.0: resolution: { @@ -3316,13 +4601,6 @@ packages: } engines: { node: ">=0.10" } - depd@1.1.2: - resolution: - { - integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==, - } - engines: { node: ">= 0.6" } - dequal@2.0.3: resolution: { @@ -3330,12 +4608,6 @@ packages: } engines: { node: ">=6" } - destr@2.0.5: - resolution: - { - integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==, - } - detect-europe-js@0.1.2: resolution: { @@ -3357,6 +4629,13 @@ packages: } engines: { node: ">=8" } + detect-newline@3.1.0: + resolution: + { + integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==, + } + engines: { node: ">=8" } + detect-node-es@1.1.0: resolution: { @@ -3433,20 +4712,19 @@ packages: integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, } - edge-runtime@2.5.9: - resolution: - { - integrity: sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==, - } - engines: { node: ">=16" } - hasBin: true - electron-to-chromium@1.5.200: resolution: { integrity: sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==, } + emittery@0.13.1: + resolution: + { + integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==, + } + engines: { node: ">=12" } + emoji-regex@8.0.0: resolution: { @@ -3459,18 +4737,6 @@ packages: integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, } - end-of-stream@1.1.0: - resolution: - { - integrity: sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ==, - } - - end-of-stream@1.4.5: - resolution: - { - integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, - } - engine.io-client@6.5.4: resolution: { @@ -3484,6 +4750,13 @@ packages: } engines: { node: ">=10.0.0" } + enhanced-resolve@5.18.3: + resolution: + { + integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==, + } + engines: { node: ">=10.13.0" } + err-code@3.0.1: resolution: { @@ -3524,10 +4797,10 @@ packages: } engines: { node: ">= 0.4" } - es-module-lexer@1.4.1: + es-module-lexer@1.7.0: resolution: { - integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==, + integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, } es-object-atoms@1.1.1: @@ -3558,194 +4831,6 @@ packages: } engines: { node: ">= 0.4" } - esbuild-android-64@0.14.47: - resolution: - { - integrity: sha512-R13Bd9+tqLVFndncMHssZrPWe6/0Kpv2/dt4aA69soX4PRxlzsVpCvoJeFE8sOEoeVEiBkI0myjlkDodXlHa0g==, - } - engines: { node: ">=12" } - cpu: [x64] - os: [android] - - esbuild-android-arm64@0.14.47: - resolution: - { - integrity: sha512-OkwOjj7ts4lBp/TL6hdd8HftIzOy/pdtbrNA4+0oVWgGG64HrdVzAF5gxtJufAPOsEjkyh1oIYvKAUinKKQRSQ==, - } - engines: { node: ">=12" } - cpu: [arm64] - os: [android] - - esbuild-darwin-64@0.14.47: - resolution: - { - integrity: sha512-R6oaW0y5/u6Eccti/TS6c/2c1xYTb1izwK3gajJwi4vIfNs1s8B1dQzI1UiC9T61YovOQVuePDcfqHLT3mUZJA==, - } - engines: { node: ">=12" } - cpu: [x64] - os: [darwin] - - esbuild-darwin-arm64@0.14.47: - resolution: - { - integrity: sha512-seCmearlQyvdvM/noz1L9+qblC5vcBrhUaOoLEDDoLInF/VQ9IkobGiLlyTPYP5dW1YD4LXhtBgOyevoIHGGnw==, - } - engines: { node: ">=12" } - cpu: [arm64] - os: [darwin] - - esbuild-freebsd-64@0.14.47: - resolution: - { - integrity: sha512-ZH8K2Q8/Ux5kXXvQMDsJcxvkIwut69KVrYQhza/ptkW50DC089bCVrJZZ3sKzIoOx+YPTrmsZvqeZERjyYrlvQ==, - } - engines: { node: ">=12" } - cpu: [x64] - os: [freebsd] - - esbuild-freebsd-arm64@0.14.47: - resolution: - { - integrity: sha512-ZJMQAJQsIOhn3XTm7MPQfCzEu5b9STNC+s90zMWe2afy9EwnHV7Ov7ohEMv2lyWlc2pjqLW8QJnz2r0KZmeAEQ==, - } - engines: { node: ">=12" } - cpu: [arm64] - os: [freebsd] - - esbuild-linux-32@0.14.47: - resolution: - { - integrity: sha512-FxZOCKoEDPRYvq300lsWCTv1kcHgiiZfNrPtEhFAiqD7QZaXrad8LxyJ8fXGcWzIFzRiYZVtB3ttvITBvAFhKw==, - } - engines: { node: ">=12" } - cpu: [ia32] - os: [linux] - - esbuild-linux-64@0.14.47: - resolution: - { - integrity: sha512-nFNOk9vWVfvWYF9YNYksZptgQAdstnDCMtR6m42l5Wfugbzu11VpMCY9XrD4yFxvPo9zmzcoUL/88y0lfJZJJw==, - } - engines: { node: ">=12" } - cpu: [x64] - os: [linux] - - esbuild-linux-arm64@0.14.47: - resolution: - { - integrity: sha512-ywfme6HVrhWcevzmsufjd4iT3PxTfCX9HOdxA7Hd+/ZM23Y9nXeb+vG6AyA6jgq/JovkcqRHcL9XwRNpWG6XRw==, - } - engines: { node: ">=12" } - cpu: [arm64] - os: [linux] - - esbuild-linux-arm@0.14.47: - resolution: - { - integrity: sha512-ZGE1Bqg/gPRXrBpgpvH81tQHpiaGxa8c9Rx/XOylkIl2ypLuOcawXEAo8ls+5DFCcRGt/o3sV+PzpAFZobOsmA==, - } - engines: { node: ">=12" } - cpu: [arm] - os: [linux] - - esbuild-linux-mips64le@0.14.47: - resolution: - { - integrity: sha512-mg3D8YndZ1LvUiEdDYR3OsmeyAew4MA/dvaEJxvyygahWmpv1SlEEnhEZlhPokjsUMfRagzsEF/d/2XF+kTQGg==, - } - engines: { node: ">=12" } - cpu: [mips64el] - os: [linux] - - esbuild-linux-ppc64le@0.14.47: - resolution: - { - integrity: sha512-WER+f3+szmnZiWoK6AsrTKGoJoErG2LlauSmk73LEZFQ/iWC+KhhDsOkn1xBUpzXWsxN9THmQFltLoaFEH8F8w==, - } - engines: { node: ">=12" } - cpu: [ppc64] - os: [linux] - - esbuild-linux-riscv64@0.14.47: - resolution: - { - integrity: sha512-1fI6bP3A3rvI9BsaaXbMoaOjLE3lVkJtLxsgLHqlBhLlBVY7UqffWBvkrX/9zfPhhVMd9ZRFiaqXnB1T7BsL2g==, - } - engines: { node: ">=12" } - cpu: [riscv64] - os: [linux] - - esbuild-linux-s390x@0.14.47: - resolution: - { - integrity: sha512-eZrWzy0xFAhki1CWRGnhsHVz7IlSKX6yT2tj2Eg8lhAwlRE5E96Hsb0M1mPSE1dHGpt1QVwwVivXIAacF/G6mw==, - } - engines: { node: ">=12" } - cpu: [s390x] - os: [linux] - - esbuild-netbsd-64@0.14.47: - resolution: - { - integrity: sha512-Qjdjr+KQQVH5Q2Q1r6HBYswFTToPpss3gqCiSw2Fpq/ua8+eXSQyAMG+UvULPqXceOwpnPo4smyZyHdlkcPppQ==, - } - engines: { node: ">=12" } - cpu: [x64] - os: [netbsd] - - esbuild-openbsd-64@0.14.47: - resolution: - { - integrity: sha512-QpgN8ofL7B9z8g5zZqJE+eFvD1LehRlxr25PBkjyyasakm4599iroUpaj96rdqRlO2ShuyqwJdr+oNqWwTUmQw==, - } - engines: { node: ">=12" } - cpu: [x64] - os: [openbsd] - - esbuild-sunos-64@0.14.47: - resolution: - { - integrity: sha512-uOeSgLUwukLioAJOiGYm3kNl+1wJjgJA8R671GYgcPgCx7QR73zfvYqXFFcIO93/nBdIbt5hd8RItqbbf3HtAQ==, - } - engines: { node: ">=12" } - cpu: [x64] - os: [sunos] - - esbuild-windows-32@0.14.47: - resolution: - { - integrity: sha512-H0fWsLTp2WBfKLBgwYT4OTfFly4Im/8B5f3ojDv1Kx//kiubVY0IQunP2Koc/fr/0wI7hj3IiBDbSrmKlrNgLQ==, - } - engines: { node: ">=12" } - cpu: [ia32] - os: [win32] - - esbuild-windows-64@0.14.47: - resolution: - { - integrity: sha512-/Pk5jIEH34T68r8PweKRi77W49KwanZ8X6lr3vDAtOlH5EumPE4pBHqkCUdELanvsT14yMXLQ/C/8XPi1pAtkQ==, - } - engines: { node: ">=12" } - cpu: [x64] - os: [win32] - - esbuild-windows-arm64@0.14.47: - resolution: - { - integrity: sha512-HFSW2lnp62fl86/qPQlqw6asIwCnEsEoNIL1h2uVMgakddf+vUuMcCbtUY1i8sst7KkgHrVKCJQB33YhhOweCQ==, - } - engines: { node: ">=12" } - cpu: [arm64] - os: [win32] - - esbuild@0.14.47: - resolution: - { - integrity: sha512-wI4ZiIfFxpkuxB8ju4MHrGwGLyp1+awEHAHVpx6w7a+1pmYIq8T9FGEVVwFo0iFierDoMj++Xq69GXWYn2EiwA==, - } - engines: { node: ">=12" } - hasBin: true - escalade@3.2.0: resolution: { @@ -3753,6 +4838,13 @@ packages: } engines: { node: ">=6" } + escape-string-regexp@2.0.0: + resolution: + { + integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==, + } + engines: { node: ">=8" } + escape-string-regexp@4.0.0: resolution: { @@ -3760,13 +4852,13 @@ packages: } engines: { node: ">=10" } - eslint-config-next@14.2.31: + eslint-config-next@15.5.3: resolution: { - integrity: sha512-sT32j4678je7SWstBM6l0kE2L+LSgAARDAxw8iloNhI4/8xwkdDesbrGCPaGWzQv+dD6f6adhB+eRSThpGkBdg==, + integrity: sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==, } peerDependencies: - eslint: ^7.23.0 || ^8.0.0 + eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 typescript: ">=3.3.1" peerDependenciesMeta: typescript: @@ -3840,14 +4932,14 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - eslint-plugin-react-hooks@5.0.0-canary-7118f5dd7-20230705: + eslint-plugin-react-hooks@5.2.0: resolution: { - integrity: sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==, + integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==, } engines: { node: ">=10" } peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 eslint-plugin-react@7.37.5: resolution: @@ -3858,6 +4950,13 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + eslint-scope@5.1.1: + resolution: + { + integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==, + } + engines: { node: ">=8.0.0" } + eslint-scope@8.4.0: resolution: { @@ -3899,6 +4998,14 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + esprima@4.0.1: + resolution: + { + integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, + } + engines: { node: ">=4" } + hasBin: true + esquery@1.6.0: resolution: { @@ -3913,6 +5020,13 @@ packages: } engines: { node: ">=4.0" } + estraverse@4.3.0: + resolution: + { + integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==, + } + engines: { node: ">=4.0" } + estraverse@5.3.0: resolution: { @@ -3939,13 +5053,6 @@ packages: } engines: { node: ">=0.10.0" } - etag@1.8.1: - resolution: - { - integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, - } - engines: { node: ">= 0.6" } - event-target-shim@6.0.2: resolution: { @@ -3953,12 +5060,6 @@ packages: } engines: { node: ">=10.13.0" } - events-intercept@2.0.0: - resolution: - { - integrity: sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==, - } - events@3.3.0: resolution: { @@ -3966,12 +5067,26 @@ packages: } engines: { node: ">=0.8.x" } - execa@3.2.0: + execa@5.1.1: resolution: { - integrity: sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==, + integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==, } - engines: { node: ^8.12.0 || >=9.7.0 } + engines: { node: ">=10" } + + exit-x@0.2.2: + resolution: + { + integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==, + } + engines: { node: ">= 0.8.0" } + + expect@30.1.2: + resolution: + { + integrity: sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } extend@3.0.2: resolution: @@ -3991,6 +5106,13 @@ packages: integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, } + fast-glob@3.3.1: + resolution: + { + integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==, + } + engines: { node: ">=8.6.0" } + fast-glob@3.3.3: resolution: { @@ -4016,16 +5138,22 @@ packages: integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==, } + fast-uri@3.1.0: + resolution: + { + integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==, + } + fastq@1.19.1: resolution: { integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==, } - fd-slicer@1.1.0: + fb-watchman@2.0.2: resolution: { - integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==, + integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==, } fdir@6.4.6: @@ -4046,12 +5174,6 @@ packages: } engines: { node: ">=16.0.0" } - file-uri-to-path@1.0.0: - resolution: - { - integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==, - } - fill-range@7.1.1: resolution: { @@ -4065,6 +5187,13 @@ packages: integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==, } + find-up@4.1.0: + resolution: + { + integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, + } + engines: { node: ">=8" } + find-up@5.0.0: resolution: { @@ -4124,53 +5253,24 @@ packages: } engines: { node: ">= 6" } + forwarded-parse@2.1.2: + resolution: + { + integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==, + } + fraction.js@4.3.7: resolution: { integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==, } - fs-extra@11.1.0: - resolution: - { - integrity: sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==, - } - engines: { node: ">=14.14" } - - fs-extra@8.1.0: - resolution: - { - integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==, - } - engines: { node: ">=6 <7 || >=8" } - - fs-minipass@1.2.7: - resolution: - { - integrity: sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==, - } - - fs-minipass@2.1.0: - resolution: - { - integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==, - } - engines: { node: ">= 8" } - fs.realpath@1.0.0: resolution: { integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==, } - fsevents@2.1.3: - resolution: - { - integrity: sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==, - } - engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } - os: [darwin] - fsevents@2.3.3: resolution: { @@ -4198,20 +5298,12 @@ packages: integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==, } - gauge@3.0.2: + gensync@1.0.0-beta.2: resolution: { - integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==, + integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, } - engines: { node: ">=10" } - deprecated: This package is no longer supported. - - generic-pool@3.4.2: - resolution: - { - integrity: sha512-H7cUpwCQSiJmAHM4c/aFu6fUfrhWXW1ncyh8ftxEPMu6AiYkHw9K8br720TGPZJbk5eOH2bynjZD1yPvdDAmag==, - } - engines: { node: ">= 4" } + engines: { node: ">=6.9.0" } get-browser-rtc@1.1.0: resolution: @@ -4219,6 +5311,13 @@ packages: integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==, } + get-caller-file@2.0.5: + resolution: + { + integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, + } + engines: { node: 6.* || 8.* || >= 10.* } + get-intrinsic@1.3.0: resolution: { @@ -4233,6 +5332,13 @@ packages: } engines: { node: ">=6" } + get-package-type@0.1.0: + resolution: + { + integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==, + } + engines: { node: ">=8.0.0" } + get-proto@1.0.1: resolution: { @@ -4240,12 +5346,12 @@ packages: } engines: { node: ">= 0.4" } - get-stream@5.2.0: + get-stream@6.0.1: resolution: { - integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==, + integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==, } - engines: { node: ">=8" } + engines: { node: ">=10" } get-symbol-description@1.1.0: resolution: @@ -4260,13 +5366,6 @@ packages: integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==, } - giget@1.2.5: - resolution: - { - integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==, - } - hasBin: true - glob-parent@5.1.2: resolution: { @@ -4281,13 +5380,11 @@ packages: } engines: { node: ">=10.13.0" } - glob@10.3.10: + glob-to-regexp@0.4.1: resolution: { - integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==, + integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==, } - engines: { node: ">=16 || 14 >=14.17" } - hasBin: true glob@10.4.5: resolution: @@ -4303,13 +5400,12 @@ packages: } deprecated: Glob versions prior to v9 are no longer supported - glob@8.1.0: + glob@9.3.5: resolution: { - integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==, + integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==, } - engines: { node: ">=12" } - deprecated: Glob versions prior to v9 are no longer supported + engines: { node: ">=16 || 14 >=14.17" } globals@14.0.0: resolution: @@ -4400,12 +5496,6 @@ packages: } engines: { node: ">= 0.4" } - has-unicode@2.0.1: - resolution: - { - integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==, - } - hasown@2.0.2: resolution: { @@ -4437,26 +5527,18 @@ packages: integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==, } + html-escaper@2.0.2: + resolution: + { + integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, + } + html-url-attributes@3.0.1: resolution: { integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==, } - http-errors@1.4.0: - resolution: - { - integrity: sha512-oLjPqve1tuOl5aRhv8GK5eHpqP1C9fb+Ol+XTLjKfLltE44zdDbEdjPSbU7Ch5rSNsVFqZn97SrMmZLdu1/YMw==, - } - engines: { node: ">= 0.6" } - - http-errors@1.7.3: - resolution: - { - integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==, - } - engines: { node: ">= 0.6" } - https-proxy-agent@5.0.1: resolution: { @@ -4464,12 +5546,19 @@ packages: } engines: { node: ">= 6" } - human-signals@1.1.1: + https-proxy-agent@7.0.6: resolution: { - integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==, + integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, } - engines: { node: ">=8.12.0" } + engines: { node: ">= 14" } + + human-signals@2.1.0: + resolution: + { + integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, + } + engines: { node: ">=10.17.0" } hyperhtml-style@0.1.3: resolution: @@ -4477,13 +5566,6 @@ packages: integrity: sha512-IvLy8MzHTSJ0fDpSzrb8rcdnla6yROEmNBSxInEMyIFu2DQkbmpadTf6B4fHvnytN6iHL2gGwpe5/jHL3wMi+A==, } - iconv-lite@0.4.24: - resolution: - { - integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==, - } - engines: { node: ">=0.10.0" } - ieee754@1.2.1: resolution: { @@ -4504,12 +5586,6 @@ packages: } engines: { node: ">= 4" } - immediate@3.0.6: - resolution: - { - integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==, - } - immer@10.1.1: resolution: { @@ -4529,6 +5605,20 @@ packages: } engines: { node: ">=6" } + import-in-the-middle@1.14.2: + resolution: + { + integrity: sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==, + } + + import-local@3.2.0: + resolution: + { + integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==, + } + engines: { node: ">=8" } + hasBin: true + imurmurhash@0.1.4: resolution: { @@ -4536,6 +5626,13 @@ packages: } engines: { node: ">=0.8.19" } + index-to-position@1.1.0: + resolution: + { + integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==, + } + engines: { node: ">=18" } + inflight@1.0.6: resolution: { @@ -4543,12 +5640,6 @@ packages: } deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - inherits@2.0.1: - resolution: - { - integrity: sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==, - } - inherits@2.0.4: resolution: { @@ -4620,6 +5711,12 @@ packages: integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, } + is-arrayish@0.3.2: + resolution: + { + integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==, + } + is-async-function@2.1.1: resolution: { @@ -4709,6 +5806,13 @@ packages: } engines: { node: ">=8" } + is-generator-fn@2.1.0: + resolution: + { + integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==, + } + engines: { node: ">=6" } + is-generator-function@1.1.0: resolution: { @@ -4846,12 +5950,6 @@ packages: } engines: { node: ">= 0.4" } - isarray@0.0.1: - resolution: - { - integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==, - } - isarray@2.0.5: resolution: { @@ -4864,6 +5962,41 @@ packages: integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, } + istanbul-lib-coverage@3.2.2: + resolution: + { + integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==, + } + engines: { node: ">=8" } + + istanbul-lib-instrument@6.0.3: + resolution: + { + integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==, + } + engines: { node: ">=10" } + + istanbul-lib-report@3.0.1: + resolution: + { + integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==, + } + engines: { node: ">=10" } + + istanbul-lib-source-maps@5.0.6: + resolution: + { + integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==, + } + engines: { node: ">=10" } + + istanbul-reports@3.2.0: + resolution: + { + integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==, + } + engines: { node: ">=8" } + iterator.prototype@1.1.5: resolution: { @@ -4871,19 +6004,174 @@ packages: } engines: { node: ">= 0.4" } - jackspeak@2.3.6: - resolution: - { - integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==, - } - engines: { node: ">=14" } - jackspeak@3.4.3: resolution: { integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, } + jest-changed-files@30.0.5: + resolution: + { + integrity: sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-circus@30.1.3: + resolution: + { + integrity: sha512-Yf3dnhRON2GJT4RYzM89t/EXIWNxKTpWTL9BfF3+geFetWP4XSvJjiU1vrWplOiUkmq8cHLiwuhz+XuUp9DscA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-cli@30.1.3: + resolution: + { + integrity: sha512-G8E2Ol3OKch1DEeIBl41NP7OiC6LBhfg25Btv+idcusmoUSpqUkbrneMqbW9lVpI/rCKb/uETidb7DNteheuAQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.1.3: + resolution: + { + integrity: sha512-M/f7gqdQEPgZNA181Myz+GXCe8jXcJsGjCMXUzRj22FIXsZOyHNte84e0exntOvdPaeh9tA0w+B8qlP2fAezfw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + "@types/node": "*" + esbuild-register: ">=3.4.0" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.1.2: + resolution: + { + integrity: sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-docblock@30.0.1: + resolution: + { + integrity: sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-each@30.1.0: + resolution: + { + integrity: sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-environment-node@30.1.2: + resolution: + { + integrity: sha512-w8qBiXtqGWJ9xpJIA98M0EIoq079GOQRQUyse5qg1plShUCQ0Ek1VTTcczqKrn3f24TFAgFtT+4q3aOXvjbsuA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-haste-map@30.1.0: + resolution: + { + integrity: sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-leak-detector@30.1.0: + resolution: + { + integrity: sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-matcher-utils@30.1.2: + resolution: + { + integrity: sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-message-util@30.1.0: + resolution: + { + integrity: sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-mock@30.0.5: + resolution: + { + integrity: sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-pnp-resolver@1.2.3: + resolution: + { + integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==, + } + engines: { node: ">=6" } + peerDependencies: + jest-resolve: "*" + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: + { + integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-resolve-dependencies@30.1.3: + resolution: + { + integrity: sha512-DNfq3WGmuRyHRHfEet+Zm3QOmVFtIarUOQHHryKPc0YL9ROfgWZxl4+aZq/VAzok2SS3gZdniP+dO4zgo59hBg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-resolve@30.1.3: + resolution: + { + integrity: sha512-DI4PtTqzw9GwELFS41sdMK32Ajp3XZQ8iygeDMWkxlRhm7uUTOFSZFVZABFuxr0jvspn8MAYy54NxZCsuCTSOw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-runner@30.1.3: + resolution: + { + integrity: sha512-dd1ORcxQraW44Uz029TtXj85W11yvLpDuIzNOlofrC8GN+SgDlgY4BvyxJiVeuabA1t6idjNbX59jLd2oplOGQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-runtime@30.1.3: + resolution: + { + integrity: sha512-WS8xgjuNSphdIGnleQcJ3AKE4tBKOVP+tKhCD0u+Tb2sBmsU8DxfbBpZX7//+XOz81zVs4eFpJQwBNji2Y07DA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-snapshot@30.1.2: + resolution: + { + integrity: sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest-util@29.7.0: resolution: { @@ -4891,6 +6179,34 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + jest-util@30.0.5: + resolution: + { + integrity: sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-validate@30.1.0: + resolution: + { + integrity: sha512-7P3ZlCFW/vhfQ8pE7zW6Oi4EzvuB4sgR72Q1INfW9m0FGo0GADYlPwIkf4CyPq7wq85g+kPMtPOHNAdWHeBOaA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-watcher@30.1.3: + resolution: + { + integrity: sha512-6jQUZCP1BTL2gvG9E4YF06Ytq4yMb4If6YoQGRR6PpjtqOXSP3sKe2kqwB6SQ+H9DezOfZaSLnmka1NtGm3fCQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-worker@27.5.1: + resolution: + { + integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==, + } + engines: { node: ">= 10.13.0" } + jest-worker@29.7.0: resolution: { @@ -4898,6 +6214,26 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + jest-worker@30.1.0: + resolution: + { + integrity: sha512-uvWcSjlwAAgIu133Tt77A05H7RIk3Ho8tZL50bQM2AkvLdluw9NG48lRCl3Dt+MOH719n/0nnb5YxUwcuJiKRA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest@30.1.3: + resolution: + { + integrity: sha512-Ry+p2+NLk6u8Agh5yVqELfUJvRfV51hhVBRIB5yZPY7mU0DGBmOuFG5GebZbMbm86cdQNK0fhJuDX8/1YorISQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + jiti@1.21.7: resolution: { @@ -4911,12 +6247,26 @@ packages: integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==, } + js-levenshtein@1.1.6: + resolution: + { + integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==, + } + engines: { node: ">=0.10.0" } + js-tokens@4.0.0: resolution: { integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, } + js-yaml@3.14.1: + resolution: + { + integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==, + } + hasBin: true + js-yaml@4.1.0: resolution: { @@ -4950,12 +6300,6 @@ packages: integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, } - json-schema-to-ts@1.6.4: - resolution: - { - integrity: sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==, - } - json-schema-traverse@0.4.1: resolution: { @@ -4981,17 +6325,13 @@ packages: } hasBin: true - jsonfile@4.0.0: + json5@2.2.3: resolution: { - integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==, - } - - jsonfile@6.2.0: - resolution: - { - integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==, + integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, } + engines: { node: ">=6" } + hasBin: true jsx-ast-utils@3.3.5: resolution: @@ -5019,6 +6359,13 @@ packages: } engines: { node: ">=0.10" } + leven@3.1.0: + resolution: + { + integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==, + } + engines: { node: ">=6" } + levn@0.4.1: resolution: { @@ -5026,12 +6373,6 @@ packages: } engines: { node: ">= 0.8.0" } - lie@3.1.1: - resolution: - { - integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==, - } - lighterhtml@4.2.0: resolution: { @@ -5051,11 +6392,19 @@ packages: integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, } - localforage@1.10.0: + loader-runner@4.3.0: resolution: { - integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==, + integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==, } + engines: { node: ">=6.11.5" } + + locate-path@5.0.0: + resolution: + { + integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, + } + engines: { node: ">=8" } locate-path@6.0.0: resolution: @@ -5076,6 +6425,12 @@ packages: integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==, } + lodash.memoize@4.1.2: + resolution: + { + integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==, + } + lodash.merge@4.6.2: resolution: { @@ -5101,6 +6456,12 @@ packages: integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, } + lru-cache@5.1.1: + resolution: + { + integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, + } + lru-cache@6.0.0: resolution: { @@ -5116,19 +6477,25 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - magic-string@0.27.0: + magic-string@0.30.19: resolution: { - integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==, + integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==, + } + + magic-string@0.30.8: + resolution: + { + integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==, } engines: { node: ">=12" } - make-dir@3.1.0: + make-dir@4.0.0: resolution: { - integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==, + integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, } - engines: { node: ">=8" } + engines: { node: ">=10" } make-error@1.3.6: resolution: @@ -5136,6 +6503,12 @@ packages: integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==, } + makeerror@1.0.12: + resolution: + { + integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==, + } + math-intrinsics@1.1.0: resolution: { @@ -5211,14 +6584,6 @@ packages: } engines: { node: ">= 8" } - micro@9.3.5-canary.3: - resolution: - { - integrity: sha512-viYIo9PefV+w9dvoIBh1gI44Mvx1BOk67B4BpC2QK77qdY0xZF0Q+vWLt/BII6cLkIc8rLmSIcJaB/OrXXKe1g==, - } - engines: { node: ">= 8.0.0" } - hasBin: true - micromark-core-commonmark@2.0.3: resolution: { @@ -5386,6 +6751,13 @@ packages: } engines: { node: ">=10" } + minimatch@8.0.4: + resolution: + { + integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==, + } + engines: { node: ">=16 || 14 >=14.17" } + minimatch@9.0.5: resolution: { @@ -5399,23 +6771,10 @@ packages: integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, } - minipass@2.9.0: + minipass@4.2.8: resolution: { - integrity: sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==, - } - - minipass@3.3.6: - resolution: - { - integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==, - } - engines: { node: ">=8" } - - minipass@5.0.0: - resolution: - { - integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==, + integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==, } engines: { node: ">=8" } @@ -5426,57 +6785,16 @@ packages: } engines: { node: ">=16 || 14 >=14.17" } - minizlib@1.3.3: - resolution: - { - integrity: sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==, - } - - minizlib@2.1.2: - resolution: - { - integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==, - } - engines: { node: ">= 8" } - mitt@3.0.1: resolution: { integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==, } - mkdirp@0.5.6: + module-details-from-path@1.0.4: resolution: { - integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==, - } - hasBin: true - - mkdirp@1.0.4: - resolution: - { - integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==, - } - engines: { node: ">=10" } - hasBin: true - - mlly@1.7.4: - resolution: - { - integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==, - } - - mri@1.2.0: - resolution: - { - integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, - } - engines: { node: ">=4" } - - ms@2.1.1: - resolution: - { - integrity: sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==, + integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==, } ms@2.1.3: @@ -5545,24 +6863,27 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@14.2.31: + next@15.5.3: resolution: { - integrity: sha512-Wyw1m4t8PhqG+or5a1U/Deb888YApC4rAez9bGhHkTsfwAy4SWKVro0GhEx4sox1856IbLhvhce2hAA6o8vkog==, + integrity: sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==, } - engines: { node: ">=18.17.0" } + engines: { node: ^18.18.0 || ^19.8.0 || >= 20.0.0 } hasBin: true peerDependencies: "@opentelemetry/api": ^1.1.0 - "@playwright/test": ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 + "@playwright/test": ^1.51.1 + babel-plugin-react-compiler: "*" + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 sass: ^1.3.0 peerDependenciesMeta: "@opentelemetry/api": optional: true "@playwright/test": optional: true + babel-plugin-react-compiler: + optional: true sass: optional: true @@ -5578,36 +6899,6 @@ packages: integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==, } - node-fetch-native@1.6.7: - resolution: - { - integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==, - } - - node-fetch@2.6.7: - resolution: - { - integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==, - } - engines: { node: 4.x || >=6.0.0 } - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - - node-fetch@2.6.9: - resolution: - { - integrity: sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==, - } - engines: { node: 4.x || >=6.0.0 } - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - node-fetch@2.7.0: resolution: { @@ -5620,12 +6911,11 @@ packages: encoding: optional: true - node-gyp-build@4.8.4: + node-int64@0.4.0: resolution: { - integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==, + integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==, } - hasBin: true node-releases@2.0.19: resolution: @@ -5633,14 +6923,6 @@ packages: integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==, } - nopt@5.0.0: - resolution: - { - integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==, - } - engines: { node: ">=6" } - hasBin: true - normalize-path@3.0.0: resolution: { @@ -5662,13 +6944,6 @@ packages: } engines: { node: ">=8" } - npmlog@5.0.1: - resolution: - { - integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==, - } - deprecated: This package is no longer supported. - nuqs@2.4.3: resolution: { @@ -5690,14 +6965,6 @@ packages: react-router-dom: optional: true - nypm@0.5.4: - resolution: - { - integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==, - } - engines: { node: ^14.16.0 || >=16.10.0 } - hasBin: true - oauth@0.9.15: resolution: { @@ -5774,12 +7041,6 @@ packages: } engines: { node: ">= 0.4" } - ohash@1.1.6: - resolution: - { - integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==, - } - oidc-token-hash@5.1.1: resolution: { @@ -5787,12 +7048,6 @@ packages: } engines: { node: ^10.13.0 || >=12.0.0 } - once@1.3.3: - resolution: - { - integrity: sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==, - } - once@1.4.0: resolution: { @@ -5806,6 +7061,36 @@ packages: } engines: { node: ">=6" } + openapi-fetch@0.14.0: + resolution: + { + integrity: sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==, + } + + openapi-react-query@0.5.0: + resolution: + { + integrity: sha512-VtyqiamsbWsdSWtXmj/fAR+m9nNxztsof6h8ZIsjRj8c8UR/x9AIwHwd60IqwgymmFwo7qfSJQ1ZzMJrtqjQVg==, + } + peerDependencies: + "@tanstack/react-query": ^5.25.0 + openapi-fetch: ^0.14.0 + + openapi-typescript-helpers@0.0.15: + resolution: + { + integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==, + } + + openapi-typescript@7.9.1: + resolution: + { + integrity: sha512-9gJtoY04mk6iPMbToPjPxEAtfXZ0dTsMZtsgUI8YZta0btPPig9DJFP4jlerQD/7QOwYgb0tl+zLUpDf7vb7VA==, + } + hasBin: true + peerDependencies: + typescript: ^5.x + openid-client@5.7.1: resolution: { @@ -5819,13 +7104,6 @@ packages: } engines: { node: ">= 0.8.0" } - os-paths@4.4.0: - resolution: - { - integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==, - } - engines: { node: ">= 6.0" } - own-keys@1.0.1: resolution: { @@ -5833,12 +7111,12 @@ packages: } engines: { node: ">= 0.4" } - p-finally@2.0.1: + p-limit@2.3.0: resolution: { - integrity: sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==, + integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, } - engines: { node: ">=8" } + engines: { node: ">=6" } p-limit@3.1.0: resolution: @@ -5847,6 +7125,13 @@ packages: } engines: { node: ">=10" } + p-locate@4.1.0: + resolution: + { + integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==, + } + engines: { node: ">=8" } + p-locate@5.0.0: resolution: { @@ -5854,6 +7139,13 @@ packages: } engines: { node: ">=10" } + p-try@2.2.0: + resolution: + { + integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, + } + engines: { node: ">=6" } + package-json-from-dist@1.0.1: resolution: { @@ -5880,18 +7172,12 @@ packages: } engines: { node: ">=8" } - parse-ms@2.1.0: + parse-json@8.3.0: resolution: { - integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==, - } - engines: { node: ">=6" } - - path-browserify@1.0.1: - resolution: - { - integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==, + integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==, } + engines: { node: ">=18" } path-exists@4.0.0: resolution: @@ -5914,13 +7200,6 @@ packages: } engines: { node: ">=8" } - path-match@1.2.4: - resolution: - { - integrity: sha512-UWlehEdqu36jmh4h5CWJ7tARp1OEVKGHKm6+dg9qMq5RKUTV5WJrGgaZ3dN2m7WFAXDbjlHzvJvL/IUpy84Ktw==, - } - deprecated: This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions - path-parse@1.0.7: resolution: { @@ -5934,24 +7213,6 @@ packages: } engines: { node: ">=16 || 14 >=14.18" } - path-to-regexp@1.9.0: - resolution: - { - integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==, - } - - path-to-regexp@6.1.0: - resolution: - { - integrity: sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==, - } - - path-to-regexp@6.2.1: - resolution: - { - integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==, - } - path-type@4.0.0: resolution: { @@ -5959,41 +7220,31 @@ packages: } engines: { node: ">=8" } - pathe@1.1.2: - resolution: - { - integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==, - } - - pathe@2.0.3: - resolution: - { - integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, - } - - pend@1.2.0: - resolution: - { - integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==, - } - - perfect-debounce@1.0.0: - resolution: - { - integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==, - } - perfect-freehand@1.2.2: resolution: { integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==, } - picocolors@1.0.0: + pg-int8@1.0.1: resolution: { - integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==, + integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==, } + engines: { node: ">=4.0.0" } + + pg-protocol@1.10.3: + resolution: + { + integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==, + } + + pg-types@2.2.0: + resolution: + { + integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==, + } + engines: { node: ">=4" } picocolors@1.1.1: resolution: @@ -6029,11 +7280,19 @@ packages: } engines: { node: ">= 6" } - pkg-types@1.3.1: + pkg-dir@4.2.0: resolution: { - integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==, + integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==, } + engines: { node: ">=8" } + + pluralize@8.0.0: + resolution: + { + integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==, + } + engines: { node: ">=4" } possible-typed-array-names@1.1.0: resolution: @@ -6111,6 +7370,34 @@ packages: } engines: { node: ^10 || ^12 || >=14 } + postgres-array@2.0.0: + resolution: + { + integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==, + } + engines: { node: ">=4" } + + postgres-bytea@1.0.0: + resolution: + { + integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==, + } + engines: { node: ">=0.10.0" } + + postgres-date@1.0.7: + resolution: + { + integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==, + } + engines: { node: ">=0.10.0" } + + postgres-interval@1.2.0: + resolution: + { + integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==, + } + engines: { node: ">=0.10.0" } + preact-render-to-string@5.2.6: resolution: { @@ -6146,12 +7433,12 @@ packages: integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==, } - pretty-ms@7.0.1: + pretty-format@30.0.5: resolution: { - integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==, + integrity: sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==, } - engines: { node: ">=10" } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } progress@2.0.3: resolution: @@ -6160,12 +7447,6 @@ packages: } engines: { node: ">=0.4.0" } - promisepipe@3.0.0: - resolution: - { - integrity: sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==, - } - prop-types@15.8.1: resolution: { @@ -6196,12 +7477,6 @@ packages: integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==, } - pump@3.0.3: - resolution: - { - integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==, - } - punycode@2.3.1: resolution: { @@ -6209,6 +7484,12 @@ packages: } engines: { node: ">=6" } + pure-rand@7.0.1: + resolution: + { + integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==, + } + qr.js@0.0.0: resolution: { @@ -6227,19 +7508,6 @@ packages: integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==, } - raw-body@2.4.1: - resolution: - { - integrity: sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==, - } - engines: { node: ">= 0.8" } - - rc9@2.1.2: - resolution: - { - integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==, - } - react-dom@18.3.1: resolution: { @@ -6271,6 +7539,12 @@ packages: integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, } + react-is@18.3.1: + resolution: + { + integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, + } + react-markdown@9.1.0: resolution: { @@ -6357,13 +7631,6 @@ packages: } engines: { node: ">= 6" } - readdirp@3.3.0: - resolution: - { - integrity: sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==, - } - engines: { node: ">=8.10.0" } - readdirp@3.6.0: resolution: { @@ -6439,6 +7706,19 @@ packages: integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==, } + remeda@2.31.1: + resolution: + { + integrity: sha512-FRZefcuXbmCoYt8hAITAzW4t8i/RERaGk/+GtRN90eV3NHxsnRKCDIOJVrwrQ6zz77TG/Xyi9mGRfiJWT7DK1g==, + } + + require-directory@2.1.1: + resolution: + { + integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, + } + engines: { node: ">=0.10.0" } + require-from-string@2.0.2: resolution: { @@ -6446,6 +7726,13 @@ packages: } engines: { node: ">=0.10.0" } + require-in-the-middle@7.5.2: + resolution: + { + integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==, + } + engines: { node: ">=8.6.0" } + reraf@1.1.1: resolution: { @@ -6458,6 +7745,13 @@ packages: integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==, } + resolve-cwd@3.0.0: + resolution: + { + integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==, + } + engines: { node: ">=8" } + resolve-from@4.0.0: resolution: { @@ -6507,20 +7801,12 @@ packages: } engines: { iojs: ">=1.0.0", node: ">=0.10.0" } - rimraf@3.0.2: + rollup@4.50.1: resolution: { - integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==, + integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==, } - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - - rollup@2.79.2: - resolution: - { - integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==, - } - engines: { node: ">=10.0.0" } + engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true rtcstats@https://codeload.github.com/whereby/rtcstats/tar.gz/63bcb6420d76d34161b39e494524ae73aa6dd70d: @@ -6570,12 +7856,6 @@ packages: } engines: { node: ">= 0.4" } - safer-buffer@2.1.2: - resolution: - { - integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, - } - sass@1.90.0: resolution: { @@ -6590,6 +7870,13 @@ packages: integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==, } + schema-utils@4.3.2: + resolution: + { + integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==, + } + engines: { node: ">= 10.13.0" } + sdp-transform@2.15.0: resolution: { @@ -6610,14 +7897,6 @@ packages: } hasBin: true - semver@7.3.5: - resolution: - { - integrity: sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==, - } - engines: { node: ">=10" } - hasBin: true - semver@7.7.2: resolution: { @@ -6626,10 +7905,10 @@ packages: engines: { node: ">=10" } hasBin: true - set-blocking@2.0.0: + serialize-javascript@6.0.2: resolution: { - integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==, + integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==, } set-function-length@1.2.2: @@ -6653,11 +7932,12 @@ packages: } engines: { node: ">= 0.4" } - setprototypeof@1.1.1: + sharp@0.34.3: resolution: { - integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==, + integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==, } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } shebang-command@2.0.0: resolution: @@ -6673,6 +7953,12 @@ packages: } engines: { node: ">=8" } + shimmer@1.2.1: + resolution: + { + integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==, + } + side-channel-list@1.0.0: resolution: { @@ -6707,13 +7993,6 @@ packages: integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==, } - signal-exit@4.0.2: - resolution: - { - integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==, - } - engines: { node: ">=14" } - signal-exit@4.1.0: resolution: { @@ -6727,6 +8006,19 @@ packages: integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==, } + simple-swizzle@0.2.2: + resolution: + { + integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==, + } + + slash@3.0.0: + resolution: + { + integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==, + } + engines: { node: ">=8" } + socket.io-client@4.7.2: resolution: { @@ -6748,6 +8040,18 @@ packages: } engines: { node: ">=0.10.0" } + source-map-support@0.5.13: + resolution: + { + integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==, + } + + source-map-support@0.5.21: + resolution: + { + integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==, + } + source-map@0.5.7: resolution: { @@ -6768,6 +8072,12 @@ packages: integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==, } + sprintf-js@1.0.3: + resolution: + { + integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==, + } + sprintf-js@1.1.3: resolution: { @@ -6780,6 +8090,13 @@ packages: integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==, } + stack-utils@2.0.6: + resolution: + { + integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==, + } + engines: { node: ">=10" } + stacktrace-parser@0.1.11: resolution: { @@ -6793,19 +8110,6 @@ packages: integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==, } - stat-mode@0.3.0: - resolution: - { - integrity: sha512-QjMLR0A3WwFY2aZdV0okfFEJB5TRjkggXZjxP3A1RsWsNHNu3YPv8btmtc6iCFZ0Rul3FE93OYogvhOUClU+ng==, - } - - statuses@1.5.0: - resolution: - { - integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==, - } - engines: { node: ">= 0.6" } - stop-iteration-iterator@1.1.0: resolution: { @@ -6813,24 +8117,12 @@ packages: } engines: { node: ">= 0.4" } - stream-to-array@2.3.0: + string-length@4.0.2: resolution: { - integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==, + integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==, } - - stream-to-promise@2.2.0: - resolution: - { - integrity: sha512-HAGUASw8NT0k8JvIVutB2Y/9iBk7gpgEyAudXwNJmZERdMITGdajOa4VJfD/kNiA3TppQpTP4J+CtcHwdzKBAw==, - } - - streamsearch@1.1.0: - resolution: - { - integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==, - } - engines: { node: ">=10.0.0" } + engines: { node: ">=10" } string-width@4.2.3: resolution: @@ -6920,6 +8212,13 @@ packages: } engines: { node: ">=4" } + strip-bom@4.0.0: + resolution: + { + integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==, + } + engines: { node: ">=8" } + strip-final-newline@2.0.0: resolution: { @@ -6946,16 +8245,16 @@ packages: integrity: sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==, } - styled-jsx@5.1.1: + styled-jsx@5.1.6: resolution: { - integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==, + integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==, } engines: { node: ">= 12.0.0" } peerDependencies: "@babel/core": "*" babel-plugin-macros: "*" - react: ">= 16.8.0 || 17.x.x || ^18.0.0-0" + react: ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" peerDependenciesMeta: "@babel/core": optional: true @@ -6976,6 +8275,13 @@ packages: engines: { node: ">=16 || 14 >=14.17" } hasBin: true + supports-color@10.2.0: + resolution: + { + integrity: sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==, + } + engines: { node: ">=18" } + supports-color@7.2.0: resolution: { @@ -7004,6 +8310,13 @@ packages: } engines: { node: ">= 0.4" } + synckit@0.11.11: + resolution: + { + integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==, + } + engines: { node: ^14.18.0 || >=16.0.0 } + tailwindcss@3.4.17: resolution: { @@ -7012,19 +8325,46 @@ packages: engines: { node: ">=14.0.0" } hasBin: true - tar@4.4.18: + tapable@2.2.3: resolution: { - integrity: sha512-ZuOtqqmkV9RE1+4odd+MhBpibmCxNP6PJhH/h2OqNuotTX7/XHPZQJv2pKvWMplFH9SIZZhitehh6vBH6LO8Pg==, + integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==, } - engines: { node: ">=4.5" } + engines: { node: ">=6" } - tar@6.2.1: + terser-webpack-plugin@5.3.14: resolution: { - integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==, + integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==, + } + engines: { node: ">= 10.13.0" } + peerDependencies: + "@swc/core": "*" + esbuild: "*" + uglify-js: "*" + webpack: ^5.1.0 + peerDependenciesMeta: + "@swc/core": + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.44.0: + resolution: + { + integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==, } engines: { node: ">=10" } + hasBin: true + + test-exclude@6.0.0: + resolution: + { + integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==, + } + engines: { node: ">=8" } thenify-all@1.6.0: resolution: @@ -7039,19 +8379,6 @@ packages: integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, } - time-span@4.0.0: - resolution: - { - integrity: sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==, - } - engines: { node: ">=10" } - - tinyexec@0.3.2: - resolution: - { - integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, - } - tinyglobby@0.2.14: resolution: { @@ -7059,6 +8386,12 @@ packages: } engines: { node: ">=12.0.0" } + tmpl@1.0.5: + resolution: + { + integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==, + } + to-regex-range@5.0.1: resolution: { @@ -7066,26 +8399,12 @@ packages: } engines: { node: ">=8.0" } - toidentifier@1.0.0: - resolution: - { - integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==, - } - engines: { node: ">=0.6" } - tr46@0.0.3: resolution: { integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==, } - tree-kill@1.2.2: - resolution: - { - integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==, - } - hasBin: true - trim-lines@3.0.1: resolution: { @@ -7113,11 +8432,35 @@ packages: integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==, } - ts-morph@12.0.0: + ts-jest@29.4.1: resolution: { - integrity: sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==, + integrity: sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==, } + engines: { node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0 } + hasBin: true + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 || ^30.0.0 + "@jest/types": ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: "*" + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: ">=4.3 <6" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true ts-node@10.9.1: resolution: @@ -7136,12 +8479,6 @@ packages: "@swc/wasm": optional: true - ts-toolbelt@6.15.5: - resolution: - { - integrity: sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==, - } - tsconfig-paths@3.15.0: resolution: { @@ -7161,6 +8498,20 @@ packages: } engines: { node: ">= 0.8.0" } + type-detect@4.0.8: + resolution: + { + integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==, + } + engines: { node: ">=4" } + + type-fest@0.21.3: + resolution: + { + integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==, + } + engines: { node: ">=10" } + type-fest@0.7.1: resolution: { @@ -7168,6 +8519,13 @@ packages: } engines: { node: ">=8" } + type-fest@4.41.0: + resolution: + { + integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==, + } + engines: { node: ">=16" } + typed-array-buffer@1.0.3: resolution: { @@ -7196,14 +8554,6 @@ packages: } engines: { node: ">= 0.4" } - typescript@4.9.5: - resolution: - { - integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==, - } - engines: { node: ">=4.2.0" } - hasBin: true - typescript@5.9.2: resolution: { @@ -7244,12 +8594,6 @@ packages: integrity: sha512-v+Z8Jal+GtmKGtJ34GIQlCJAxrDt9kbjpNsNvYoAXFyr4gNfWlD4uJJuoNNu/0UTVaKvQwHaSU095YDl71lKPw==, } - ufo@1.6.1: - resolution: - { - integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==, - } - uglify-js@3.19.3: resolution: { @@ -7270,12 +8614,6 @@ packages: integrity: sha512-o0QVGuFg24FK765Qdd5kk0zU/U4dEsCtN/GSiwNI9i8xsSVtjIAOdTaVhLwZ1nrbWxFVMxNDDl+9fednsOMsBw==, } - uid-promise@1.0.0: - resolution: - { - integrity: sha512-R8375j0qwXyIu/7R0tjdF06/sElHqbmdmWC9M2qQHpEVbvE4I5+38KJI7LUUmQMp7NVq4tKHiBMkT0NFM453Ig==, - } - umap@1.0.2: resolution: { @@ -7289,25 +8627,12 @@ packages: } engines: { node: ">= 0.4" } - uncrypto@0.1.3: - resolution: - { - integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==, - } - undici-types@7.10.0: resolution: { integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==, } - undici@5.28.4: - resolution: - { - integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==, - } - engines: { node: ">=14.0" } - unified@11.0.5: resolution: { @@ -7344,26 +8669,11 @@ packages: integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==, } - universalify@0.1.2: + unplugin@1.0.1: resolution: { - integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==, + integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==, } - engines: { node: ">= 4.0.0" } - - universalify@2.0.1: - resolution: - { - integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==, - } - engines: { node: ">= 10.0.0" } - - unpipe@1.0.0: - resolution: - { - integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==, - } - engines: { node: ">= 0.8" } unrs-resolver@1.11.1: resolution: @@ -7386,6 +8696,12 @@ packages: integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==, } + uri-js-replace@1.0.1: + resolution: + { + integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==, + } + uri-js@4.4.1: resolution: { @@ -7430,14 +8746,6 @@ packages: integrity: sha512-Fykw5U4eZESbq739BeLvEBFRuJODfrlmjx5eJux7W817LjRaq4b7/i4t2zxQmhcX+fAj4nMfRdTzO4tmwLKn0w==, } - uuid@3.3.2: - resolution: - { - integrity: sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==, - } - deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. - hasBin: true - uuid@8.3.2: resolution: { @@ -7464,13 +8772,12 @@ packages: integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==, } - vercel@37.14.0: + v8-to-istanbul@9.3.0: resolution: { - integrity: sha512-ZSEvhARyJBn4YnEVZULsvti8/OHd5txRCgJqEhNIyo/XXSvBJSvlCjA+SE1zraqn0rqyEOG3+56N3kh1Enk8Tg==, + integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==, } - engines: { node: ">= 16" } - hasBin: true + engines: { node: ">=10.12.0" } vfile-message@4.0.3: resolution: @@ -7484,18 +8791,25 @@ packages: integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, } + walker@1.0.8: + resolution: + { + integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==, + } + + watchpack@2.4.4: + resolution: + { + integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==, + } + engines: { node: ">=10.13.0" } + wavesurfer.js@7.10.1: resolution: { integrity: sha512-tF1ptFCAi8SAqKbM1e7705zouLC3z4ulXCg15kSP5dQ7VDV30Q3x/xFRcuVIYTT5+jB/PdkhiBRCfsMshZG1Ug==, } - web-vitals@0.2.4: - resolution: - { - integrity: sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg==, - } - webidl-conversions@3.0.1: resolution: { @@ -7509,6 +8823,25 @@ packages: } engines: { node: ">=10.13.0" } + webpack-virtual-modules@0.5.0: + resolution: + { + integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==, + } + + webpack@5.101.3: + resolution: + { + integrity: sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==, + } + engines: { node: ">=10.13.0" } + hasBin: true + peerDependencies: + webpack-cli: "*" + peerDependenciesMeta: + webpack-cli: + optional: true + webrtc-adapter@9.0.3: resolution: { @@ -7558,12 +8891,6 @@ packages: engines: { node: ">= 8" } hasBin: true - wide-align@1.1.5: - resolution: - { - integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==, - } - word-wrap@1.2.5: resolution: { @@ -7597,6 +8924,13 @@ packages: integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, } + write-file-atomic@5.0.1: + resolution: + { + integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==, + } + engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + ws@8.17.1: resolution: { @@ -7612,20 +8946,6 @@ packages: utf-8-validate: optional: true - xdg-app-paths@5.1.0: - resolution: - { - integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==, - } - engines: { node: ">=6" } - - xdg-portable@7.3.0: - resolution: - { - integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==, - } - engines: { node: ">= 6.0" } - xmlhttprequest-ssl@2.0.0: resolution: { @@ -7633,6 +8953,20 @@ packages: } engines: { node: ">=0.4.0" } + xtend@4.0.2: + resolution: + { + integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, + } + engines: { node: ">=0.4" } + + y18n@5.0.8: + resolution: + { + integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, + } + engines: { node: ">=10" } + yallist@3.1.1: resolution: { @@ -7645,6 +8979,12 @@ packages: integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==, } + yaml-ast-parser@0.0.43: + resolution: + { + integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==, + } + yaml@1.10.2: resolution: { @@ -7660,25 +9000,19 @@ packages: engines: { node: ">= 14.6" } hasBin: true - yauzl-clone@1.0.4: + yargs-parser@21.1.1: resolution: { - integrity: sha512-igM2RRCf3k8TvZoxR2oguuw4z1xasOnA31joCqHIyLkeWrvAc2Jgay5ISQ2ZplinkoGaJ6orCz56Ey456c5ESA==, + integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==, } - engines: { node: ">=6" } + engines: { node: ">=12" } - yauzl-promise@2.1.3: + yargs@17.7.2: resolution: { - integrity: sha512-A1pf6fzh6eYkK0L4Qp7g9jzJSDrM6nN0bOn5T0IbY4Yo3w+YkWlHFkJP7mzknMXjqusHFHlKsK2N+4OLsK2MRA==, - } - engines: { node: ">=6" } - - yauzl@2.10.0: - resolution: - { - integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==, + integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==, } + engines: { node: ">=12" } yn@3.1.1: resolution: @@ -7694,6 +9028,12 @@ packages: } engines: { node: ">=10" } + zod@4.1.5: + resolution: + { + integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==, + } + zwitch@2.0.4: resolution: { @@ -7703,11 +9043,10 @@ packages: snapshots: "@alloc/quick-lru@5.2.0": {} - "@apidevtools/json-schema-ref-parser@11.6.4": + "@ampproject/remapping@2.3.0": dependencies: - "@jsdevtools/ono": 7.1.3 - "@types/json-schema": 7.0.15 - js-yaml: 4.1.0 + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.30 "@ark-ui/react@5.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: @@ -7779,6 +9118,28 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + "@babel/compat-data@7.28.0": {} + + "@babel/core@7.28.3": + dependencies: + "@ampproject/remapping": 2.3.0 + "@babel/code-frame": 7.27.1 + "@babel/generator": 7.28.3 + "@babel/helper-compilation-targets": 7.27.2 + "@babel/helper-module-transforms": 7.28.3(@babel/core@7.28.3) + "@babel/helpers": 7.28.3 + "@babel/parser": 7.28.3 + "@babel/template": 7.27.2 + "@babel/traverse": 7.28.3 + "@babel/types": 7.28.2 + convert-source-map: 2.0.0 + debug: 4.4.1(supports-color@9.4.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + "@babel/generator@7.28.0": dependencies: "@babel/parser": 7.28.0 @@ -7787,6 +9148,22 @@ snapshots: "@jridgewell/trace-mapping": 0.3.30 jsesc: 3.1.0 + "@babel/generator@7.28.3": + dependencies: + "@babel/parser": 7.28.3 + "@babel/types": 7.28.2 + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.30 + jsesc: 3.1.0 + + "@babel/helper-compilation-targets@7.27.2": + dependencies: + "@babel/compat-data": 7.28.0 + "@babel/helper-validator-option": 7.27.1 + browserslist: 4.25.2 + lru-cache: 5.1.1 + semver: 6.3.1 + "@babel/helper-globals@7.28.0": {} "@babel/helper-module-imports@7.27.1": @@ -7796,14 +9173,121 @@ snapshots: transitivePeerDependencies: - supports-color + "@babel/helper-module-transforms@7.28.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-module-imports": 7.27.1 + "@babel/helper-validator-identifier": 7.27.1 + "@babel/traverse": 7.28.3 + transitivePeerDependencies: + - supports-color + + "@babel/helper-plugin-utils@7.27.1": {} + "@babel/helper-string-parser@7.27.1": {} "@babel/helper-validator-identifier@7.27.1": {} + "@babel/helper-validator-option@7.27.1": {} + + "@babel/helpers@7.28.3": + dependencies: + "@babel/template": 7.27.2 + "@babel/types": 7.28.2 + "@babel/parser@7.28.0": dependencies: "@babel/types": 7.28.2 + "@babel/parser@7.28.3": + dependencies: + "@babel/types": 7.28.2 + + "@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + "@babel/runtime@7.28.2": {} "@babel/template@7.27.2": @@ -7824,11 +9308,25 @@ snapshots: transitivePeerDependencies: - supports-color + "@babel/traverse@7.28.3": + dependencies: + "@babel/code-frame": 7.27.1 + "@babel/generator": 7.28.3 + "@babel/helper-globals": 7.28.0 + "@babel/parser": 7.28.3 + "@babel/template": 7.27.2 + "@babel/types": 7.28.2 + debug: 4.4.1(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + "@babel/types@7.28.2": dependencies: "@babel/helper-string-parser": 7.27.1 "@babel/helper-validator-identifier": 7.27.1 + "@bcoe/v8-coverage@0.2.3": {} + "@chakra-ui/react@3.24.2(@emotion/react@11.14.0(@types/react@18.2.20)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@ark-ui/react": 5.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -7846,18 +9344,15 @@ snapshots: "@cspotcode/source-map-support@0.8.1": dependencies: "@jridgewell/trace-mapping": 0.3.9 + optional: true - "@edge-runtime/format@2.2.1": {} - - "@edge-runtime/node-utils@2.3.0": {} - - "@edge-runtime/ponyfill@2.4.2": {} - - "@edge-runtime/primitives@4.1.0": {} - - "@edge-runtime/vm@3.2.0": + "@daily-co/daily-js@0.84.0": dependencies: - "@edge-runtime/primitives": 4.1.0 + "@babel/runtime": 7.28.2 + "@sentry/browser": 8.55.0 + bowser: 2.12.1 + dequal: 2.0.3 + events: 3.3.0 "@emnapi/core@1.4.5": dependencies: @@ -7987,8 +9482,6 @@ snapshots: "@eslint/core": 0.15.2 levn: 0.4.1 - "@fastify/busboy@2.1.1": {} - "@floating-ui/core@1.7.3": dependencies: "@floating-ui/utils": 0.2.10 @@ -8027,17 +9520,6 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 - "@hey-api/openapi-ts@0.48.3(typescript@5.9.2)": - dependencies: - "@apidevtools/json-schema-ref-parser": 11.6.4 - c12: 1.11.1 - camelcase: 8.0.0 - commander: 12.1.0 - handlebars: 4.7.8 - typescript: 5.9.2 - transitivePeerDependencies: - - magicast - "@humanfs/core@0.19.1": {} "@humanfs/node@0.16.6": @@ -8051,6 +9533,92 @@ snapshots: "@humanwhocodes/retry@0.4.3": {} + "@img/sharp-darwin-arm64@0.34.3": + optionalDependencies: + "@img/sharp-libvips-darwin-arm64": 1.2.0 + optional: true + + "@img/sharp-darwin-x64@0.34.3": + optionalDependencies: + "@img/sharp-libvips-darwin-x64": 1.2.0 + optional: true + + "@img/sharp-libvips-darwin-arm64@1.2.0": + optional: true + + "@img/sharp-libvips-darwin-x64@1.2.0": + optional: true + + "@img/sharp-libvips-linux-arm64@1.2.0": + optional: true + + "@img/sharp-libvips-linux-arm@1.2.0": + optional: true + + "@img/sharp-libvips-linux-ppc64@1.2.0": + optional: true + + "@img/sharp-libvips-linux-s390x@1.2.0": + optional: true + + "@img/sharp-libvips-linux-x64@1.2.0": + optional: true + + "@img/sharp-libvips-linuxmusl-arm64@1.2.0": + optional: true + + "@img/sharp-libvips-linuxmusl-x64@1.2.0": + optional: true + + "@img/sharp-linux-arm64@0.34.3": + optionalDependencies: + "@img/sharp-libvips-linux-arm64": 1.2.0 + optional: true + + "@img/sharp-linux-arm@0.34.3": + optionalDependencies: + "@img/sharp-libvips-linux-arm": 1.2.0 + optional: true + + "@img/sharp-linux-ppc64@0.34.3": + optionalDependencies: + "@img/sharp-libvips-linux-ppc64": 1.2.0 + optional: true + + "@img/sharp-linux-s390x@0.34.3": + optionalDependencies: + "@img/sharp-libvips-linux-s390x": 1.2.0 + optional: true + + "@img/sharp-linux-x64@0.34.3": + optionalDependencies: + "@img/sharp-libvips-linux-x64": 1.2.0 + optional: true + + "@img/sharp-linuxmusl-arm64@0.34.3": + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64": 1.2.0 + optional: true + + "@img/sharp-linuxmusl-x64@0.34.3": + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64": 1.2.0 + optional: true + + "@img/sharp-wasm32@0.34.3": + dependencies: + "@emnapi/runtime": 1.4.5 + optional: true + + "@img/sharp-win32-arm64@0.34.3": + optional: true + + "@img/sharp-win32-ia32@0.34.3": + optional: true + + "@img/sharp-win32-x64@0.34.3": + optional: true + "@internationalized/date@3.8.2": dependencies: "@swc/helpers": 0.5.17 @@ -8059,7 +9627,7 @@ snapshots: dependencies: "@swc/helpers": 0.5.17 - "@ioredis/commands@1.3.0": {} + "@ioredis/commands@1.3.1": {} "@isaacs/cliui@8.0.2": dependencies: @@ -8070,10 +9638,189 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + "@istanbuljs/load-nyc-config@1.1.0": + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + "@istanbuljs/schema@0.1.3": {} + + "@jest/console@30.1.2": + dependencies: + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + jest-message-util: 30.1.0 + jest-util: 30.0.5 + slash: 3.0.0 + + "@jest/core@30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))": + dependencies: + "@jest/console": 30.1.2 + "@jest/pattern": 30.0.1 + "@jest/reporters": 30.1.3 + "@jest/test-result": 30.1.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.0 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.0.5 + jest-config: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) + jest-haste-map: 30.1.0 + jest-message-util: 30.1.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.1.3 + jest-resolve-dependencies: 30.1.3 + jest-runner: 30.1.3 + jest-runtime: 30.1.3 + jest-snapshot: 30.1.2 + jest-util: 30.0.5 + jest-validate: 30.1.0 + jest-watcher: 30.1.3 + micromatch: 4.0.8 + pretty-format: 30.0.5 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + "@jest/diff-sequences@30.0.1": {} + + "@jest/environment@30.1.2": + dependencies: + "@jest/fake-timers": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + jest-mock: 30.0.5 + + "@jest/expect-utils@30.1.2": + dependencies: + "@jest/get-type": 30.1.0 + + "@jest/expect@30.1.2": + dependencies: + expect: 30.1.2 + jest-snapshot: 30.1.2 + transitivePeerDependencies: + - supports-color + + "@jest/fake-timers@30.1.2": + dependencies: + "@jest/types": 30.0.5 + "@sinonjs/fake-timers": 13.0.5 + "@types/node": 24.2.1 + jest-message-util: 30.1.0 + jest-mock: 30.0.5 + jest-util: 30.0.5 + + "@jest/get-type@30.1.0": {} + + "@jest/globals@30.1.2": + dependencies: + "@jest/environment": 30.1.2 + "@jest/expect": 30.1.2 + "@jest/types": 30.0.5 + jest-mock: 30.0.5 + transitivePeerDependencies: + - supports-color + + "@jest/pattern@30.0.1": + dependencies: + "@types/node": 24.2.1 + jest-regex-util: 30.0.1 + + "@jest/reporters@30.1.3": + dependencies: + "@bcoe/v8-coverage": 0.2.3 + "@jest/console": 30.1.2 + "@jest/test-result": 30.1.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + "@jridgewell/trace-mapping": 0.3.30 + "@types/node": 24.2.1 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit-x: 0.2.2 + glob: 10.4.5 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.1.0 + jest-util: 30.0.5 + jest-worker: 30.1.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + "@jest/schemas@29.6.3": dependencies: "@sinclair/typebox": 0.27.8 + "@jest/schemas@30.0.5": + dependencies: + "@sinclair/typebox": 0.34.41 + + "@jest/snapshot-utils@30.1.2": + dependencies: + "@jest/types": 30.0.5 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + "@jest/source-map@30.0.1": + dependencies: + "@jridgewell/trace-mapping": 0.3.30 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + "@jest/test-result@30.1.3": + dependencies: + "@jest/console": 30.1.2 + "@jest/types": 30.0.5 + "@types/istanbul-lib-coverage": 2.0.6 + collect-v8-coverage: 1.0.2 + + "@jest/test-sequencer@30.1.3": + dependencies: + "@jest/test-result": 30.1.3 + graceful-fs: 4.2.11 + jest-haste-map: 30.1.0 + slash: 3.0.0 + + "@jest/transform@30.1.2": + dependencies: + "@babel/core": 7.28.3 + "@jest/types": 30.0.5 + "@jridgewell/trace-mapping": 0.3.30 + babel-plugin-istanbul: 7.0.0 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.1.0 + jest-regex-util: 30.0.1 + jest-util: 30.0.5 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + "@jest/types@29.6.3": dependencies: "@jest/schemas": 29.6.3 @@ -8083,6 +9830,16 @@ snapshots: "@types/yargs": 17.0.33 chalk: 4.1.2 + "@jest/types@30.0.5": + dependencies: + "@jest/pattern": 30.0.1 + "@jest/schemas": 30.0.5 + "@types/istanbul-lib-coverage": 2.0.6 + "@types/istanbul-reports": 3.0.4 + "@types/node": 24.2.1 + "@types/yargs": 17.0.33 + chalk: 4.1.2 + "@jridgewell/gen-mapping@0.3.13": dependencies: "@jridgewell/sourcemap-codec": 1.5.5 @@ -8090,6 +9847,11 @@ snapshots: "@jridgewell/resolve-uri@3.1.2": {} + "@jridgewell/source-map@0.3.11": + dependencies: + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.31 + "@jridgewell/sourcemap-codec@1.5.5": {} "@jridgewell/trace-mapping@0.3.30": @@ -8097,27 +9859,16 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 + "@jridgewell/trace-mapping@0.3.31": + dependencies: + "@jridgewell/resolve-uri": 3.1.2 + "@jridgewell/sourcemap-codec": 1.5.5 + "@jridgewell/trace-mapping@0.3.9": dependencies: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 - - "@jsdevtools/ono@7.1.3": {} - - "@mapbox/node-pre-gyp@1.0.11": - dependencies: - detect-libc: 2.0.4 - https-proxy-agent: 5.0.1 - make-dir: 3.1.0 - node-fetch: 2.7.0 - nopt: 5.0.0 - npmlog: 5.0.1 - rimraf: 3.0.2 - semver: 7.7.2 - tar: 6.2.1 - transitivePeerDependencies: - - encoding - - supports-color + optional: true "@napi-rs/wasm-runtime@0.2.12": dependencies: @@ -8126,37 +9877,34 @@ snapshots: "@tybys/wasm-util": 0.10.0 optional: true - "@next/env@14.2.31": {} + "@next/env@15.5.3": {} - "@next/eslint-plugin-next@14.2.31": + "@next/eslint-plugin-next@15.5.3": dependencies: - glob: 10.3.10 + fast-glob: 3.3.1 - "@next/swc-darwin-arm64@14.2.31": + "@next/swc-darwin-arm64@15.5.3": optional: true - "@next/swc-darwin-x64@14.2.31": + "@next/swc-darwin-x64@15.5.3": optional: true - "@next/swc-linux-arm64-gnu@14.2.31": + "@next/swc-linux-arm64-gnu@15.5.3": optional: true - "@next/swc-linux-arm64-musl@14.2.31": + "@next/swc-linux-arm64-musl@15.5.3": optional: true - "@next/swc-linux-x64-gnu@14.2.31": + "@next/swc-linux-x64-gnu@15.5.3": optional: true - "@next/swc-linux-x64-musl@14.2.31": + "@next/swc-linux-x64-musl@15.5.3": optional: true - "@next/swc-win32-arm64-msvc@14.2.31": + "@next/swc-win32-arm64-msvc@15.5.3": optional: true - "@next/swc-win32-ia32-msvc@14.2.31": - optional: true - - "@next/swc-win32-x64-msvc@14.2.31": + "@next/swc-win32-x64-msvc@15.5.3": optional: true "@nodelib/fs.scandir@2.1.5": @@ -8173,6 +9921,276 @@ snapshots: "@nolyfill/is-core-module@1.0.39": {} + "@opentelemetry/api-logs@0.203.0": + dependencies: + "@opentelemetry/api": 1.9.0 + + "@opentelemetry/api-logs@0.204.0": + dependencies: + "@opentelemetry/api": 1.9.0 + + "@opentelemetry/api-logs@0.57.2": + dependencies: + "@opentelemetry/api": 1.9.0 + + "@opentelemetry/api@1.9.0": {} + + "@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + + "@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/semantic-conventions": 1.37.0 + + "@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/semantic-conventions": 1.37.0 + + "@opentelemetry/instrumentation-amqplib@0.50.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-connect@0.47.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + "@types/connect": 3.4.38 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-dataloader@0.21.1(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-express@0.52.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-fs@0.23.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-generic-pool@0.47.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-graphql@0.51.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-hapi@0.50.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-http@0.203.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.0.1(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-ioredis@0.52.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.204.0(@opentelemetry/api@1.9.0) + "@opentelemetry/redis-common": 0.38.0 + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-kafkajs@0.13.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-knex@0.48.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-koa@0.51.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-lru-memoizer@0.48.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-mongodb@0.56.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-mongoose@0.50.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-mysql2@0.50.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + "@opentelemetry/sql-common": 0.41.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-mysql@0.49.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + "@types/mysql": 2.15.27 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-pg@0.55.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + "@opentelemetry/sql-common": 0.41.0(@opentelemetry/api@1.9.0) + "@types/pg": 8.15.4 + "@types/pg-pool": 2.0.6 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-redis@0.51.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/redis-common": 0.38.0 + "@opentelemetry/semantic-conventions": 1.37.0 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-tedious@0.22.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + "@types/tedious": 4.0.14 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation-undici@0.14.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/api-logs": 0.203.0 + import-in-the-middle: 1.14.2 + require-in-the-middle: 7.5.2 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation@0.204.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/api-logs": 0.204.0 + import-in-the-middle: 1.14.2 + require-in-the-middle: 7.5.2 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/api-logs": 0.57.2 + "@types/shimmer": 1.2.0 + import-in-the-middle: 1.14.2 + require-in-the-middle: 7.5.2 + semver: 7.7.2 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + "@opentelemetry/redis-common@0.38.0": {} + + "@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + + "@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/resources": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + + "@opentelemetry/semantic-conventions@1.37.0": {} + + "@opentelemetry/sql-common@0.41.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@pandacss/is-valid-prop@0.54.0": {} "@panva/hkdf@1.2.1": {} @@ -8241,6 +10259,15 @@ snapshots: "@pkgjs/parseargs@0.11.0": optional: true + "@pkgr/core@0.2.9": {} + + "@prisma/instrumentation@6.14.0(@opentelemetry/api@1.9.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/instrumentation": 0.57.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + "@radix-ui/primitive@1.1.3": {} "@radix-ui/react-arrow@1.1.7(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": @@ -8420,6 +10447,29 @@ snapshots: "@radix-ui/rect@1.1.1": {} + "@redocly/ajv@8.11.3": + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + "@redocly/config@0.22.2": {} + + "@redocly/openapi-core@1.34.5(supports-color@10.2.0)": + dependencies: + "@redocly/ajv": 8.11.3 + "@redocly/config": 0.22.2 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.0) + js-levenshtein: 1.1.6 + js-yaml: 4.1.0 + minimatch: 5.1.6 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + "@reduxjs/toolkit@2.8.2(react@18.3.1)": dependencies: "@standard-schema/spec": 1.0.0 @@ -8431,202 +10481,410 @@ snapshots: optionalDependencies: react: 18.3.1 - "@rollup/plugin-commonjs@24.0.0(rollup@2.79.2)": + "@rollup/plugin-commonjs@28.0.1(rollup@4.50.1)": dependencies: - "@rollup/pluginutils": 5.2.0(rollup@2.79.2) + "@rollup/pluginutils": 5.2.0(rollup@4.50.1) commondir: 1.0.1 estree-walker: 2.0.2 - glob: 8.1.0 + fdir: 6.4.6(picomatch@4.0.3) is-reference: 1.2.1 - magic-string: 0.27.0 + magic-string: 0.30.19 + picomatch: 4.0.3 optionalDependencies: - rollup: 2.79.2 + rollup: 4.50.1 - "@rollup/pluginutils@4.2.1": - dependencies: - estree-walker: 2.0.2 - picomatch: 2.3.1 - - "@rollup/pluginutils@5.2.0(rollup@2.79.2)": + "@rollup/pluginutils@5.2.0(rollup@4.50.1)": dependencies: "@types/estree": 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 optionalDependencies: - rollup: 2.79.2 + rollup: 4.50.1 + + "@rollup/rollup-android-arm-eabi@4.50.1": + optional: true + + "@rollup/rollup-android-arm64@4.50.1": + optional: true + + "@rollup/rollup-darwin-arm64@4.50.1": + optional: true + + "@rollup/rollup-darwin-x64@4.50.1": + optional: true + + "@rollup/rollup-freebsd-arm64@4.50.1": + optional: true + + "@rollup/rollup-freebsd-x64@4.50.1": + optional: true + + "@rollup/rollup-linux-arm-gnueabihf@4.50.1": + optional: true + + "@rollup/rollup-linux-arm-musleabihf@4.50.1": + optional: true + + "@rollup/rollup-linux-arm64-gnu@4.50.1": + optional: true + + "@rollup/rollup-linux-arm64-musl@4.50.1": + optional: true + + "@rollup/rollup-linux-loongarch64-gnu@4.50.1": + optional: true + + "@rollup/rollup-linux-ppc64-gnu@4.50.1": + optional: true + + "@rollup/rollup-linux-riscv64-gnu@4.50.1": + optional: true + + "@rollup/rollup-linux-riscv64-musl@4.50.1": + optional: true + + "@rollup/rollup-linux-s390x-gnu@4.50.1": + optional: true + + "@rollup/rollup-linux-x64-gnu@4.50.1": + optional: true + + "@rollup/rollup-linux-x64-musl@4.50.1": + optional: true + + "@rollup/rollup-openharmony-arm64@4.50.1": + optional: true + + "@rollup/rollup-win32-arm64-msvc@4.50.1": + optional: true + + "@rollup/rollup-win32-ia32-msvc@4.50.1": + optional: true + + "@rollup/rollup-win32-x64-msvc@4.50.1": + optional: true "@rtsao/scc@1.1.0": {} "@rushstack/eslint-patch@1.12.0": {} - "@sentry-internal/feedback@7.120.4": + "@sentry-internal/browser-utils@10.11.0": dependencies: - "@sentry/core": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 + "@sentry/core": 10.11.0 - "@sentry-internal/replay-canvas@7.120.4": + "@sentry-internal/browser-utils@8.55.0": dependencies: - "@sentry/core": 7.120.4 - "@sentry/replay": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 + "@sentry/core": 8.55.0 - "@sentry-internal/tracing@7.120.4": + "@sentry-internal/feedback@10.11.0": dependencies: - "@sentry/core": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 + "@sentry/core": 10.11.0 - "@sentry/browser@7.120.4": + "@sentry-internal/feedback@8.55.0": dependencies: - "@sentry-internal/feedback": 7.120.4 - "@sentry-internal/replay-canvas": 7.120.4 - "@sentry-internal/tracing": 7.120.4 - "@sentry/core": 7.120.4 - "@sentry/integrations": 7.120.4 - "@sentry/replay": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 + "@sentry/core": 8.55.0 - "@sentry/cli@1.77.3": + "@sentry-internal/replay-canvas@10.11.0": + dependencies: + "@sentry-internal/replay": 10.11.0 + "@sentry/core": 10.11.0 + + "@sentry-internal/replay-canvas@8.55.0": + dependencies: + "@sentry-internal/replay": 8.55.0 + "@sentry/core": 8.55.0 + + "@sentry-internal/replay@10.11.0": + dependencies: + "@sentry-internal/browser-utils": 10.11.0 + "@sentry/core": 10.11.0 + + "@sentry-internal/replay@8.55.0": + dependencies: + "@sentry-internal/browser-utils": 8.55.0 + "@sentry/core": 8.55.0 + + "@sentry/babel-plugin-component-annotate@4.3.0": {} + + "@sentry/browser@10.11.0": + dependencies: + "@sentry-internal/browser-utils": 10.11.0 + "@sentry-internal/feedback": 10.11.0 + "@sentry-internal/replay": 10.11.0 + "@sentry-internal/replay-canvas": 10.11.0 + "@sentry/core": 10.11.0 + + "@sentry/browser@8.55.0": + dependencies: + "@sentry-internal/browser-utils": 8.55.0 + "@sentry-internal/feedback": 8.55.0 + "@sentry-internal/replay": 8.55.0 + "@sentry-internal/replay-canvas": 8.55.0 + "@sentry/core": 8.55.0 + + "@sentry/bundler-plugin-core@4.3.0": + dependencies: + "@babel/core": 7.28.3 + "@sentry/babel-plugin-component-annotate": 4.3.0 + "@sentry/cli": 2.53.0 + dotenv: 16.6.1 + find-up: 5.0.0 + glob: 9.3.5 + magic-string: 0.30.8 + unplugin: 1.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + "@sentry/cli-darwin@2.53.0": + optional: true + + "@sentry/cli-linux-arm64@2.53.0": + optional: true + + "@sentry/cli-linux-arm@2.53.0": + optional: true + + "@sentry/cli-linux-i686@2.53.0": + optional: true + + "@sentry/cli-linux-x64@2.53.0": + optional: true + + "@sentry/cli-win32-arm64@2.53.0": + optional: true + + "@sentry/cli-win32-i686@2.53.0": + optional: true + + "@sentry/cli-win32-x64@2.53.0": + optional: true + + "@sentry/cli@2.53.0": dependencies: https-proxy-agent: 5.0.1 - mkdirp: 0.5.6 node-fetch: 2.7.0 progress: 2.0.3 proxy-from-env: 1.1.0 which: 2.0.2 + optionalDependencies: + "@sentry/cli-darwin": 2.53.0 + "@sentry/cli-linux-arm": 2.53.0 + "@sentry/cli-linux-arm64": 2.53.0 + "@sentry/cli-linux-i686": 2.53.0 + "@sentry/cli-linux-x64": 2.53.0 + "@sentry/cli-win32-arm64": 2.53.0 + "@sentry/cli-win32-i686": 2.53.0 + "@sentry/cli-win32-x64": 2.53.0 transitivePeerDependencies: - encoding - supports-color - "@sentry/core@7.120.4": - dependencies: - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 + "@sentry/core@10.11.0": {} - "@sentry/integrations@7.120.4": - dependencies: - "@sentry/core": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 - localforage: 1.10.0 + "@sentry/core@8.55.0": {} - "@sentry/nextjs@7.120.4(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)": + "@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)": dependencies: - "@rollup/plugin-commonjs": 24.0.0(rollup@2.79.2) - "@sentry/core": 7.120.4 - "@sentry/integrations": 7.120.4 - "@sentry/node": 7.120.4 - "@sentry/react": 7.120.4(react@18.3.1) - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 - "@sentry/vercel-edge": 7.120.4 - "@sentry/webpack-plugin": 1.21.0 + "@opentelemetry/api": 1.9.0 + "@opentelemetry/semantic-conventions": 1.37.0 + "@rollup/plugin-commonjs": 28.0.1(rollup@4.50.1) + "@sentry-internal/browser-utils": 10.11.0 + "@sentry/bundler-plugin-core": 4.3.0 + "@sentry/core": 10.11.0 + "@sentry/node": 10.11.0 + "@sentry/opentelemetry": 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) + "@sentry/react": 10.11.0(react@18.3.1) + "@sentry/vercel-edge": 10.11.0 + "@sentry/webpack-plugin": 4.3.0(webpack@5.101.3) chalk: 3.0.0 - next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) - react: 18.3.1 + next: 15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) resolve: 1.22.8 - rollup: 2.79.2 + rollup: 4.50.1 stacktrace-parser: 0.1.11 transitivePeerDependencies: + - "@opentelemetry/context-async-hooks" + - "@opentelemetry/core" + - "@opentelemetry/sdk-trace-base" - encoding + - react + - supports-color + - webpack + + "@sentry/node-core@10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/context-async-hooks": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/resources": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/sdk-trace-base": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + "@sentry/core": 10.11.0 + "@sentry/opentelemetry": 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) + import-in-the-middle: 1.14.2 + + "@sentry/node@10.11.0": + dependencies: + "@opentelemetry/api": 1.9.0 + "@opentelemetry/context-async-hooks": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-amqplib": 0.50.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-connect": 0.47.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-dataloader": 0.21.1(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-express": 0.52.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-fs": 0.23.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-generic-pool": 0.47.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-graphql": 0.51.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-hapi": 0.50.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-http": 0.203.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-ioredis": 0.52.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-kafkajs": 0.13.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-knex": 0.48.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-koa": 0.51.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-lru-memoizer": 0.48.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-mongodb": 0.56.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-mongoose": 0.50.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-mysql": 0.49.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-mysql2": 0.50.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-pg": 0.55.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-redis": 0.51.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-tedious": 0.22.0(@opentelemetry/api@1.9.0) + "@opentelemetry/instrumentation-undici": 0.14.0(@opentelemetry/api@1.9.0) + "@opentelemetry/resources": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/sdk-trace-base": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + "@prisma/instrumentation": 6.14.0(@opentelemetry/api@1.9.0) + "@sentry/core": 10.11.0 + "@sentry/node-core": 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) + "@sentry/opentelemetry": 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) + import-in-the-middle: 1.14.2 + minimatch: 9.0.5 + transitivePeerDependencies: - supports-color - "@sentry/node@7.120.4": + "@sentry/opentelemetry@10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)": dependencies: - "@sentry-internal/tracing": 7.120.4 - "@sentry/core": 7.120.4 - "@sentry/integrations": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 + "@opentelemetry/api": 1.9.0 + "@opentelemetry/context-async-hooks": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/sdk-trace-base": 2.1.0(@opentelemetry/api@1.9.0) + "@opentelemetry/semantic-conventions": 1.37.0 + "@sentry/core": 10.11.0 - "@sentry/react@7.120.4(react@18.3.1)": + "@sentry/react@10.11.0(react@18.3.1)": dependencies: - "@sentry/browser": 7.120.4 - "@sentry/core": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 + "@sentry/browser": 10.11.0 + "@sentry/core": 10.11.0 hoist-non-react-statics: 3.3.2 react: 18.3.1 - "@sentry/replay@7.120.4": + "@sentry/vercel-edge@10.11.0": dependencies: - "@sentry-internal/tracing": 7.120.4 - "@sentry/core": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 + "@opentelemetry/api": 1.9.0 + "@opentelemetry/resources": 2.1.0(@opentelemetry/api@1.9.0) + "@sentry/core": 10.11.0 - "@sentry/types@7.120.4": {} - - "@sentry/utils@7.120.4": + "@sentry/webpack-plugin@4.3.0(webpack@5.101.3)": dependencies: - "@sentry/types": 7.120.4 - - "@sentry/vercel-edge@7.120.4": - dependencies: - "@sentry-internal/tracing": 7.120.4 - "@sentry/core": 7.120.4 - "@sentry/integrations": 7.120.4 - "@sentry/types": 7.120.4 - "@sentry/utils": 7.120.4 - - "@sentry/webpack-plugin@1.21.0": - dependencies: - "@sentry/cli": 1.77.3 - webpack-sources: 3.3.3 + "@sentry/bundler-plugin-core": 4.3.0 + unplugin: 1.0.1 + uuid: 9.0.1 + webpack: 5.101.3 transitivePeerDependencies: - encoding - supports-color - "@sinclair/typebox@0.25.24": {} - "@sinclair/typebox@0.27.8": {} + "@sinclair/typebox@0.34.41": {} + + "@sinonjs/commons@3.0.1": + dependencies: + type-detect: 4.0.8 + + "@sinonjs/fake-timers@13.0.5": + dependencies: + "@sinonjs/commons": 3.0.1 + "@socket.io/component-emitter@3.1.2": {} "@standard-schema/spec@1.0.0": {} "@standard-schema/utils@0.3.0": {} - "@swc/counter@0.1.3": {} + "@swc/helpers@0.5.15": + dependencies: + tslib: 2.8.1 "@swc/helpers@0.5.17": dependencies: tslib: 2.8.1 - "@swc/helpers@0.5.5": + "@tanstack/query-core@5.85.9": {} + + "@tanstack/react-query@5.85.9(react@18.3.1)": dependencies: - "@swc/counter": 0.1.3 - tslib: 2.8.1 + "@tanstack/query-core": 5.85.9 + react: 18.3.1 - "@tootallnate/once@2.0.0": {} + "@tsconfig/node10@1.0.11": + optional: true - "@ts-morph/common@0.11.1": - dependencies: - fast-glob: 3.3.3 - minimatch: 3.1.2 - mkdirp: 1.0.4 - path-browserify: 1.0.1 + "@tsconfig/node12@1.0.11": + optional: true - "@tsconfig/node10@1.0.11": {} + "@tsconfig/node14@1.0.3": + optional: true - "@tsconfig/node12@1.0.11": {} - - "@tsconfig/node14@1.0.3": {} - - "@tsconfig/node16@1.0.4": {} + "@tsconfig/node16@1.0.4": + optional: true "@tybys/wasm-util@0.10.0": dependencies: tslib: 2.8.1 optional: true + "@types/babel__core@7.20.5": + dependencies: + "@babel/parser": 7.28.0 + "@babel/types": 7.28.2 + "@types/babel__generator": 7.27.0 + "@types/babel__template": 7.4.4 + "@types/babel__traverse": 7.28.0 + + "@types/babel__generator@7.27.0": + dependencies: + "@babel/types": 7.28.2 + + "@types/babel__template@7.4.4": + dependencies: + "@babel/parser": 7.28.0 + "@babel/types": 7.28.2 + + "@types/babel__traverse@7.28.0": + dependencies: + "@babel/types": 7.28.2 + + "@types/connect@3.4.38": + dependencies: + "@types/node": 24.2.1 + "@types/debug@4.1.12": dependencies: "@types/ms": 2.1.0 + "@types/eslint-scope@3.7.7": + dependencies: + "@types/eslint": 9.6.1 + "@types/estree": 1.0.8 + + "@types/eslint@9.6.1": + dependencies: + "@types/estree": 1.0.8 + "@types/json-schema": 7.0.15 + "@types/estree-jsx@1.0.5": dependencies: "@types/estree": 1.0.8 @@ -8639,6 +10897,12 @@ snapshots: dependencies: "@types/unist": 3.0.3 + "@types/ioredis@5.0.0": + dependencies: + ioredis: 5.7.0 + transitivePeerDependencies: + - supports-color + "@types/istanbul-lib-coverage@2.0.6": {} "@types/istanbul-lib-report@3.0.3": @@ -8649,6 +10913,11 @@ snapshots: dependencies: "@types/istanbul-lib-report": 3.0.3 + "@types/jest@30.0.0": + dependencies: + expect: 30.1.2 + pretty-format: 30.0.5 + "@types/json-schema@7.0.15": {} "@types/json5@0.0.29": {} @@ -8659,19 +10928,35 @@ snapshots: "@types/ms@2.1.0": {} + "@types/mysql@2.15.27": + dependencies: + "@types/node": 24.2.1 + "@types/node-fetch@2.6.13": dependencies: "@types/node": 24.2.1 form-data: 4.0.4 - "@types/node@16.18.11": {} - "@types/node@24.2.1": dependencies: undici-types: 7.10.0 + "@types/node@24.3.1": + dependencies: + undici-types: 7.10.0 + "@types/parse-json@4.0.2": {} + "@types/pg-pool@2.0.6": + dependencies: + "@types/pg": 8.15.4 + + "@types/pg@8.15.4": + dependencies: + "@types/node": 24.2.1 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + "@types/prop-types@15.7.15": {} "@types/react@18.2.20": @@ -8682,6 +10967,14 @@ snapshots: "@types/scheduler@0.26.0": {} + "@types/shimmer@1.2.0": {} + + "@types/stack-utils@2.0.3": {} + + "@types/tedious@4.0.14": + dependencies: + "@types/node": 24.2.1 + "@types/ua-parser-js@0.7.39": {} "@types/unist@2.0.11": {} @@ -8860,166 +11153,81 @@ snapshots: "@unrs/resolver-binding-win32-x64-msvc@1.11.1": optional: true - "@upstash/redis@1.35.3": + "@webassemblyjs/ast@1.14.1": dependencies: - uncrypto: 0.1.3 + "@webassemblyjs/helper-numbers": 1.13.2 + "@webassemblyjs/helper-wasm-bytecode": 1.13.2 - "@vercel/build-utils@8.4.12": {} + "@webassemblyjs/floating-point-hex-parser@1.13.2": {} - "@vercel/edge-config-fs@0.1.0": {} + "@webassemblyjs/helper-api-error@1.13.2": {} - "@vercel/edge-config@0.4.1": + "@webassemblyjs/helper-buffer@1.14.1": {} + + "@webassemblyjs/helper-numbers@1.13.2": dependencies: - "@vercel/edge-config-fs": 0.1.0 + "@webassemblyjs/floating-point-hex-parser": 1.13.2 + "@webassemblyjs/helper-api-error": 1.13.2 + "@xtuc/long": 4.2.2 - "@vercel/error-utils@2.0.2": {} + "@webassemblyjs/helper-wasm-bytecode@1.13.2": {} - "@vercel/fun@1.1.0": + "@webassemblyjs/helper-wasm-section@1.14.1": dependencies: - "@tootallnate/once": 2.0.0 - async-listen: 1.2.0 - debug: 4.1.1 - execa: 3.2.0 - fs-extra: 8.1.0 - generic-pool: 3.4.2 - micro: 9.3.5-canary.3 - ms: 2.1.1 - node-fetch: 2.6.7 - path-match: 1.2.4 - promisepipe: 3.0.0 - semver: 7.3.5 - stat-mode: 0.3.0 - stream-to-promise: 2.2.0 - tar: 4.4.18 - tree-kill: 1.2.2 - uid-promise: 1.0.0 - uuid: 3.3.2 - xdg-app-paths: 5.1.0 - yauzl-promise: 2.1.3 - transitivePeerDependencies: - - encoding - - supports-color + "@webassemblyjs/ast": 1.14.1 + "@webassemblyjs/helper-buffer": 1.14.1 + "@webassemblyjs/helper-wasm-bytecode": 1.13.2 + "@webassemblyjs/wasm-gen": 1.14.1 - "@vercel/gatsby-plugin-vercel-analytics@1.0.11": + "@webassemblyjs/ieee754@1.13.2": dependencies: - web-vitals: 0.2.4 + "@xtuc/ieee754": 1.2.0 - "@vercel/gatsby-plugin-vercel-builder@2.0.56": + "@webassemblyjs/leb128@1.13.2": dependencies: - "@sinclair/typebox": 0.25.24 - "@vercel/build-utils": 8.4.12 - "@vercel/routing-utils": 3.1.0 - esbuild: 0.14.47 - etag: 1.8.1 - fs-extra: 11.1.0 + "@xtuc/long": 4.2.2 - "@vercel/go@3.2.0": {} + "@webassemblyjs/utf8@1.13.2": {} - "@vercel/hydrogen@1.0.9": + "@webassemblyjs/wasm-edit@1.14.1": dependencies: - "@vercel/static-config": 3.0.0 - ts-morph: 12.0.0 + "@webassemblyjs/ast": 1.14.1 + "@webassemblyjs/helper-buffer": 1.14.1 + "@webassemblyjs/helper-wasm-bytecode": 1.13.2 + "@webassemblyjs/helper-wasm-section": 1.14.1 + "@webassemblyjs/wasm-gen": 1.14.1 + "@webassemblyjs/wasm-opt": 1.14.1 + "@webassemblyjs/wasm-parser": 1.14.1 + "@webassemblyjs/wast-printer": 1.14.1 - "@vercel/kv@2.0.0": + "@webassemblyjs/wasm-gen@1.14.1": dependencies: - "@upstash/redis": 1.35.3 + "@webassemblyjs/ast": 1.14.1 + "@webassemblyjs/helper-wasm-bytecode": 1.13.2 + "@webassemblyjs/ieee754": 1.13.2 + "@webassemblyjs/leb128": 1.13.2 + "@webassemblyjs/utf8": 1.13.2 - "@vercel/next@4.3.18": + "@webassemblyjs/wasm-opt@1.14.1": dependencies: - "@vercel/nft": 0.27.3 - transitivePeerDependencies: - - encoding - - supports-color + "@webassemblyjs/ast": 1.14.1 + "@webassemblyjs/helper-buffer": 1.14.1 + "@webassemblyjs/wasm-gen": 1.14.1 + "@webassemblyjs/wasm-parser": 1.14.1 - "@vercel/nft@0.27.3": + "@webassemblyjs/wasm-parser@1.14.1": dependencies: - "@mapbox/node-pre-gyp": 1.0.11 - "@rollup/pluginutils": 4.2.1 - acorn: 8.15.0 - acorn-import-attributes: 1.9.5(acorn@8.15.0) - async-sema: 3.1.1 - bindings: 1.5.0 - estree-walker: 2.0.2 - glob: 7.2.3 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - node-gyp-build: 4.8.4 - resolve-from: 5.0.0 - transitivePeerDependencies: - - encoding - - supports-color + "@webassemblyjs/ast": 1.14.1 + "@webassemblyjs/helper-api-error": 1.13.2 + "@webassemblyjs/helper-wasm-bytecode": 1.13.2 + "@webassemblyjs/ieee754": 1.13.2 + "@webassemblyjs/leb128": 1.13.2 + "@webassemblyjs/utf8": 1.13.2 - "@vercel/node@3.2.24": + "@webassemblyjs/wast-printer@1.14.1": dependencies: - "@edge-runtime/node-utils": 2.3.0 - "@edge-runtime/primitives": 4.1.0 - "@edge-runtime/vm": 3.2.0 - "@types/node": 16.18.11 - "@vercel/build-utils": 8.4.12 - "@vercel/error-utils": 2.0.2 - "@vercel/nft": 0.27.3 - "@vercel/static-config": 3.0.0 - async-listen: 3.0.0 - cjs-module-lexer: 1.2.3 - edge-runtime: 2.5.9 - es-module-lexer: 1.4.1 - esbuild: 0.14.47 - etag: 1.8.1 - node-fetch: 2.6.9 - path-to-regexp: 6.2.1 - ts-morph: 12.0.0 - ts-node: 10.9.1(@types/node@16.18.11)(typescript@4.9.5) - typescript: 4.9.5 - undici: 5.28.4 - transitivePeerDependencies: - - "@swc/core" - - "@swc/wasm" - - encoding - - supports-color - - "@vercel/python@4.3.1": {} - - "@vercel/redwood@2.1.8": - dependencies: - "@vercel/nft": 0.27.3 - "@vercel/routing-utils": 3.1.0 - "@vercel/static-config": 3.0.0 - semver: 6.3.1 - ts-morph: 12.0.0 - transitivePeerDependencies: - - encoding - - supports-color - - "@vercel/remix-builder@2.2.13": - dependencies: - "@vercel/error-utils": 2.0.2 - "@vercel/nft": 0.27.3 - "@vercel/static-config": 3.0.0 - ts-morph: 12.0.0 - transitivePeerDependencies: - - encoding - - supports-color - - "@vercel/routing-utils@3.1.0": - dependencies: - path-to-regexp: 6.1.0 - optionalDependencies: - ajv: 6.12.6 - - "@vercel/ruby@2.1.0": {} - - "@vercel/static-build@2.5.34": - dependencies: - "@vercel/gatsby-plugin-vercel-analytics": 1.0.11 - "@vercel/gatsby-plugin-vercel-builder": 2.0.56 - "@vercel/static-config": 3.0.0 - ts-morph: 12.0.0 - - "@vercel/static-config@3.0.0": - dependencies: - ajv: 8.6.3 - json-schema-to-ts: 1.6.4 - ts-morph: 12.0.0 + "@webassemblyjs/ast": 1.14.1 + "@xtuc/long": 4.2.2 "@whereby.com/browser-sdk@3.13.1(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: @@ -9078,6 +11286,10 @@ snapshots: - supports-color - utf-8-validate + "@xtuc/ieee754@1.2.0": {} + + "@xtuc/long@4.2.2": {} + "@zag-js/accordion@1.21.0": dependencies: "@zag-js/anatomy": 1.21.0 @@ -9579,12 +11791,14 @@ snapshots: "@zag-js/utils@1.21.0": {} - abbrev@1.1.1: {} - acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 + acorn-import-phases@1.0.4(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -9592,6 +11806,7 @@ snapshots: acorn-walk@8.3.4: dependencies: acorn: 8.15.0 + optional: true acorn@8.15.0: {} @@ -9601,6 +11816,17 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -9608,12 +11834,18 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.6.3: + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - uri-js: 4.4.1 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 ansi-regex@5.0.1: {} @@ -9623,6 +11855,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -9632,19 +11866,15 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - aproba@2.1.0: {} - - are-we-there-yet@2.0.0: - dependencies: - delegates: 1.0.0 - readable-stream: 3.6.2 - - arg@4.1.0: {} - - arg@4.1.3: {} + arg@4.1.3: + optional: true arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -9724,14 +11954,6 @@ snapshots: async-function@1.0.0: {} - async-listen@1.2.0: {} - - async-listen@3.0.0: {} - - async-listen@3.0.1: {} - - async-sema@3.1.1: {} - asynckit@0.4.0: {} augmentor@2.2.0: @@ -9771,12 +11993,66 @@ snapshots: axobject-query@4.1.0: {} + babel-jest@30.1.2(@babel/core@7.28.3): + dependencies: + "@babel/core": 7.28.3 + "@jest/transform": 30.1.2 + "@types/babel__core": 7.20.5 + babel-plugin-istanbul: 7.0.0 + babel-preset-jest: 30.0.1(@babel/core@7.28.3) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.0: + dependencies: + "@babel/helper-plugin-utils": 7.27.1 + "@istanbuljs/load-nyc-config": 1.1.0 + "@istanbuljs/schema": 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.0.1: + dependencies: + "@babel/template": 7.27.2 + "@babel/types": 7.28.2 + "@types/babel__core": 7.20.5 + babel-plugin-macros@3.1.0: dependencies: "@babel/runtime": 7.28.2 cosmiconfig: 7.1.0 resolve: 1.22.10 + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.3): + dependencies: + "@babel/core": 7.28.3 + "@babel/plugin-syntax-async-generators": 7.8.4(@babel/core@7.28.3) + "@babel/plugin-syntax-bigint": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-class-properties": 7.12.13(@babel/core@7.28.3) + "@babel/plugin-syntax-class-static-block": 7.14.5(@babel/core@7.28.3) + "@babel/plugin-syntax-import-attributes": 7.27.1(@babel/core@7.28.3) + "@babel/plugin-syntax-import-meta": 7.10.4(@babel/core@7.28.3) + "@babel/plugin-syntax-json-strings": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-logical-assignment-operators": 7.10.4(@babel/core@7.28.3) + "@babel/plugin-syntax-nullish-coalescing-operator": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-numeric-separator": 7.10.4(@babel/core@7.28.3) + "@babel/plugin-syntax-object-rest-spread": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-optional-catch-binding": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-optional-chaining": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-private-property-in-object": 7.14.5(@babel/core@7.28.3) + "@babel/plugin-syntax-top-level-await": 7.14.5(@babel/core@7.28.3) + + babel-preset-jest@30.0.1(@babel/core@7.28.3): + dependencies: + "@babel/core": 7.28.3 + babel-plugin-jest-hoist: 30.0.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.3) + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -9785,9 +12061,7 @@ snapshots: binary-extensions@2.3.0: {} - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 + bowser@2.12.1: {} brace-expansion@1.1.12: dependencies: @@ -9809,36 +12083,23 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.2) + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + btoa@1.2.1: {} - buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} buffer@6.0.3: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 - busboy@1.6.0: - dependencies: - streamsearch: 1.1.0 - - bytes@3.1.0: {} - - c12@1.11.1: - dependencies: - chokidar: 3.6.0 - confbox: 0.1.8 - defu: 6.1.4 - dotenv: 16.6.1 - giget: 1.2.5 - jiti: 1.21.7 - mlly: 1.7.4 - ohash: 1.1.6 - pathe: 1.1.2 - perfect-debounce: 1.0.0 - pkg-types: 1.3.1 - rc9: 2.1.2 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -9860,7 +12121,9 @@ snapshots: camelcase-css@2.0.1: {} - camelcase@8.0.0: {} + camelcase@5.3.1: {} + + camelcase@6.3.0: {} caniuse-lite@1.0.30001734: {} @@ -9876,6 +12139,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + + char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -9888,18 +12155,6 @@ snapshots: dependencies: ip-range-check: 0.0.2 - chokidar@3.3.1: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.3.0 - optionalDependencies: - fsevents: 2.1.3 - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -9916,27 +12171,33 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@1.1.4: {} - - chownr@2.0.0: {} + chrome-trace-event@1.0.4: {} ci-info@3.9.0: {} - citty@0.1.6: - dependencies: - consola: 3.4.2 + ci-info@4.3.0: {} - cjs-module-lexer@1.2.3: {} + cjs-module-lexer@1.4.3: {} + + cjs-module-lexer@2.1.0: {} classnames@2.5.1: {} client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cluster-key-slot@1.1.2: {} - code-block-writer@10.1.1: {} + co@4.6.0: {} + + collect-v8-coverage@1.0.2: {} color-convert@2.0.1: dependencies: @@ -9944,7 +12205,19 @@ snapshots: color-name@1.1.4: {} - color-support@1.1.3: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + colorette@1.4.0: {} combined-stream@1.0.8: dependencies: @@ -9952,7 +12225,7 @@ snapshots: comma-separated-tokens@2.0.3: {} - commander@12.1.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -9960,18 +12233,10 @@ snapshots: concat-map@0.0.1: {} - confbox@0.1.8: {} - - consola@3.4.2: {} - - console-control-strings@1.1.0: {} - - content-type@1.0.4: {} - - convert-hrtime@3.0.0: {} - convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} + cookie@0.7.2: {} cosmiconfig@7.1.0: @@ -9982,7 +12247,8 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 - create-require@1.1.1: {} + create-require@1.1.1: + optional: true cross-spawn@7.0.6: dependencies: @@ -10018,14 +12284,16 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.1.1: - dependencies: - ms: 2.1.1 - debug@4.3.7: dependencies: ms: 2.1.3 + debug@4.4.1(supports-color@10.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.0 + debug@4.4.1(supports-color@9.4.0): dependencies: ms: 2.1.3 @@ -10036,8 +12304,14 @@ snapshots: dependencies: character-entities: 2.0.2 + dedent@1.7.0(babel-plugin-macros@3.1.0): + optionalDependencies: + babel-plugin-macros: 3.1.0 + deep-is@0.1.4: {} + deepmerge@4.3.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -10050,26 +12324,21 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - defu@6.1.4: {} - delayed-stream@1.0.0: {} - delegates@1.0.0: {} - denque@2.1.0: {} - depd@1.1.2: {} - dequal@2.0.3: {} - destr@2.0.5: {} - detect-europe-js@0.1.2: {} detect-libc@1.0.3: optional: true - detect-libc@2.0.4: {} + detect-libc@2.0.4: + optional: true + + detect-newline@3.1.0: {} detect-node-es@1.1.0: {} @@ -10079,7 +12348,8 @@ snapshots: didyoumean@1.2.2: {} - diff@4.0.2: {} + diff@4.0.2: + optional: true dlv@1.1.3: {} @@ -10113,32 +12383,14 @@ snapshots: eastasianwidth@0.2.0: {} - edge-runtime@2.5.9: - dependencies: - "@edge-runtime/format": 2.2.1 - "@edge-runtime/ponyfill": 2.4.2 - "@edge-runtime/vm": 3.2.0 - async-listen: 3.0.1 - mri: 1.2.0 - picocolors: 1.0.0 - pretty-ms: 7.0.1 - signal-exit: 4.0.2 - time-span: 4.0.0 - electron-to-chromium@1.5.200: {} + emittery@0.13.1: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} - end-of-stream@1.1.0: - dependencies: - once: 1.3.3 - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - engine.io-client@6.5.4: dependencies: "@socket.io/component-emitter": 3.1.2 @@ -10153,6 +12405,11 @@ snapshots: engine.io-parser@5.2.3: {} + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.3 + err-code@3.0.1: {} error-ex@1.3.2: @@ -10239,7 +12496,7 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.4.1: {} + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: @@ -10262,96 +12519,15 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild-android-64@0.14.47: - optional: true - - esbuild-android-arm64@0.14.47: - optional: true - - esbuild-darwin-64@0.14.47: - optional: true - - esbuild-darwin-arm64@0.14.47: - optional: true - - esbuild-freebsd-64@0.14.47: - optional: true - - esbuild-freebsd-arm64@0.14.47: - optional: true - - esbuild-linux-32@0.14.47: - optional: true - - esbuild-linux-64@0.14.47: - optional: true - - esbuild-linux-arm64@0.14.47: - optional: true - - esbuild-linux-arm@0.14.47: - optional: true - - esbuild-linux-mips64le@0.14.47: - optional: true - - esbuild-linux-ppc64le@0.14.47: - optional: true - - esbuild-linux-riscv64@0.14.47: - optional: true - - esbuild-linux-s390x@0.14.47: - optional: true - - esbuild-netbsd-64@0.14.47: - optional: true - - esbuild-openbsd-64@0.14.47: - optional: true - - esbuild-sunos-64@0.14.47: - optional: true - - esbuild-windows-32@0.14.47: - optional: true - - esbuild-windows-64@0.14.47: - optional: true - - esbuild-windows-arm64@0.14.47: - optional: true - - esbuild@0.14.47: - optionalDependencies: - esbuild-android-64: 0.14.47 - esbuild-android-arm64: 0.14.47 - esbuild-darwin-64: 0.14.47 - esbuild-darwin-arm64: 0.14.47 - esbuild-freebsd-64: 0.14.47 - esbuild-freebsd-arm64: 0.14.47 - esbuild-linux-32: 0.14.47 - esbuild-linux-64: 0.14.47 - esbuild-linux-arm: 0.14.47 - esbuild-linux-arm64: 0.14.47 - esbuild-linux-mips64le: 0.14.47 - esbuild-linux-ppc64le: 0.14.47 - esbuild-linux-riscv64: 0.14.47 - esbuild-linux-s390x: 0.14.47 - esbuild-netbsd-64: 0.14.47 - esbuild-openbsd-64: 0.14.47 - esbuild-sunos-64: 0.14.47 - esbuild-windows-32: 0.14.47 - esbuild-windows-64: 0.14.47 - esbuild-windows-arm64: 0.14.47 - escalade@3.2.0: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} - eslint-config-next@14.2.31(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2): + eslint-config-next@15.5.3(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2): dependencies: - "@next/eslint-plugin-next": 14.2.31 + "@next/eslint-plugin-next": 15.5.3 "@rushstack/eslint-patch": 1.12.0 "@typescript-eslint/eslint-plugin": 8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2))(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2) "@typescript-eslint/parser": 8.39.1(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2) @@ -10361,7 +12537,7 @@ snapshots: eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.33.0(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.33.0(jiti@1.21.7)) - eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@9.33.0(jiti@1.21.7)) + eslint-plugin-react-hooks: 5.2.0(eslint@9.33.0(jiti@1.21.7)) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -10451,7 +12627,7 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.0.0-canary-7118f5dd7-20230705(eslint@9.33.0(jiti@1.21.7)): + eslint-plugin-react-hooks@5.2.0(eslint@9.33.0(jiti@1.21.7)): dependencies: eslint: 9.33.0(jiti@1.21.7) @@ -10477,6 +12653,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -10534,6 +12715,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -10542,6 +12725,8 @@ snapshots: dependencies: estraverse: 5.3.0 + estraverse@4.3.0: {} + estraverse@5.3.0: {} estree-util-is-identifier-name@3.0.0: {} @@ -10550,27 +12735,33 @@ snapshots: esutils@2.0.3: {} - etag@1.8.1: {} - event-target-shim@6.0.2: {} - events-intercept@2.0.0: {} - events@3.3.0: {} - execa@3.2.0: + execa@5.1.1: dependencies: cross-spawn: 7.0.6 - get-stream: 5.2.0 - human-signals: 1.1.1 + get-stream: 6.0.1 + human-signals: 2.1.0 is-stream: 2.0.1 merge-stream: 2.0.0 npm-run-path: 4.0.1 onetime: 5.1.2 - p-finally: 2.0.1 signal-exit: 3.0.7 strip-final-newline: 2.0.0 + exit-x@0.2.2: {} + + expect@30.1.2: + dependencies: + "@jest/expect-utils": 30.1.2 + "@jest/get-type": 30.1.0 + jest-matcher-utils: 30.1.2 + jest-message-util: 30.1.0 + jest-mock: 30.0.5 + jest-util: 30.0.5 + extend@3.0.2: {} fake-mediastreamtrack@1.2.0: @@ -10580,6 +12771,14 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-glob@3.3.1: + dependencies: + "@nodelib/fs.stat": 2.0.5 + "@nodelib/fs.walk": 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-glob@3.3.3: dependencies: "@nodelib/fs.stat": 2.0.5 @@ -10594,13 +12793,15 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-uri@3.1.0: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 - fd-slicer@1.1.0: + fb-watchman@2.0.2: dependencies: - pend: 1.2.0 + bser: 2.1.1 fdir@6.4.6(picomatch@4.0.3): optionalDependencies: @@ -10610,14 +12811,17 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-uri-to-path@1.0.0: {} - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 find-root@1.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -10651,33 +12855,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded-parse@2.1.2: {} + fraction.js@4.3.7: {} - fs-extra@11.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fs-extra@8.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - - fs-minipass@1.2.7: - dependencies: - minipass: 2.9.0 - - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - fs.realpath@1.0.0: {} - fsevents@2.1.3: - optional: true - fsevents@2.3.3: optional: true @@ -10694,22 +12877,12 @@ snapshots: functions-have-names@1.2.3: {} - gauge@3.0.2: - dependencies: - aproba: 2.1.0 - color-support: 1.1.3 - console-control-strings: 1.1.0 - has-unicode: 2.0.1 - object-assign: 4.1.1 - signal-exit: 3.0.7 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wide-align: 1.1.5 - - generic-pool@3.4.2: {} + gensync@1.0.0-beta.2: {} get-browser-rtc@1.1.0: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10725,14 +12898,14 @@ snapshots: get-nonce@1.0.1: {} + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stream@5.2.0: - dependencies: - pump: 3.0.3 + get-stream@6.0.1: {} get-symbol-description@1.1.0: dependencies: @@ -10744,16 +12917,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - giget@1.2.5: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - defu: 6.1.4 - node-fetch-native: 1.6.7 - nypm: 0.5.4 - pathe: 2.0.3 - tar: 6.2.1 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -10762,13 +12925,7 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.3.10: - dependencies: - foreground-child: 3.3.1 - jackspeak: 2.3.6 - minimatch: 9.0.5 - minipass: 7.1.2 - path-scurry: 1.11.1 + glob-to-regexp@0.4.1: {} glob@10.4.5: dependencies: @@ -10788,13 +12945,12 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 - glob@8.1.0: + glob@9.3.5: dependencies: fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 5.1.6 - once: 1.4.0 + minimatch: 8.0.4 + minipass: 4.2.8 + path-scurry: 1.11.1 globals@14.0.0: {} @@ -10842,8 +12998,6 @@ snapshots: dependencies: has-symbols: 1.1.0 - has-unicode@2.0.1: {} - hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -10885,21 +13039,10 @@ snapshots: dependencies: react-is: 16.13.1 + html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} - http-errors@1.4.0: - dependencies: - inherits: 2.0.1 - statuses: 1.5.0 - - http-errors@1.7.3: - dependencies: - depd: 1.1.2 - inherits: 2.0.4 - setprototypeof: 1.1.1 - statuses: 1.5.0 - toidentifier: 1.0.0 - https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -10907,22 +13050,23 @@ snapshots: transitivePeerDependencies: - supports-color - human-signals@1.1.1: {} + https-proxy-agent@7.0.6(supports-color@10.2.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.1(supports-color@10.2.0) + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} hyperhtml-style@0.1.3: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} ignore@5.3.2: {} ignore@7.0.5: {} - immediate@3.0.6: {} - immer@10.1.1: {} immutable@5.1.3: {} @@ -10932,15 +13076,27 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@1.14.2: + dependencies: + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + imurmurhash@0.1.4: {} + index-to-position@1.1.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 wrappy: 1.0.2 - inherits@2.0.1: {} - inherits@2.0.4: {} inline-style-parser@0.2.4: {} @@ -10953,7 +13109,7 @@ snapshots: ioredis@5.7.0: dependencies: - "@ioredis/commands": 1.3.0 + "@ioredis/commands": 1.3.1 cluster-key-slot: 1.1.2 debug: 4.4.1(supports-color@9.4.0) denque: 2.1.0 @@ -10991,6 +13147,9 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.2: + optional: true + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -11043,6 +13202,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-generator-fn@2.1.0: {} + is-generator-function@1.1.0: dependencies: call-bound: 1.0.4 @@ -11116,12 +13277,41 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 - isarray@0.0.1: {} - isarray@2.0.5: {} isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + "@babel/core": 7.28.3 + "@babel/parser": 7.28.0 + "@istanbuljs/schema": 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + "@jridgewell/trace-mapping": 0.3.30 + debug: 4.4.1(supports-color@9.4.0) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -11131,18 +13321,274 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jackspeak@2.3.6: - dependencies: - "@isaacs/cliui": 8.0.2 - optionalDependencies: - "@pkgjs/parseargs": 0.11.0 - jackspeak@3.4.3: dependencies: "@isaacs/cliui": 8.0.2 optionalDependencies: "@pkgjs/parseargs": 0.11.0 + jest-changed-files@30.0.5: + dependencies: + execa: 5.1.1 + jest-util: 30.0.5 + p-limit: 3.1.0 + + jest-circus@30.1.3(babel-plugin-macros@3.1.0): + dependencies: + "@jest/environment": 30.1.2 + "@jest/expect": 30.1.2 + "@jest/test-result": 30.1.3 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.0(babel-plugin-macros@3.1.0) + is-generator-fn: 2.1.0 + jest-each: 30.1.0 + jest-matcher-utils: 30.1.2 + jest-message-util: 30.1.0 + jest-runtime: 30.1.3 + jest-snapshot: 30.1.2 + jest-util: 30.0.5 + p-limit: 3.1.0 + pretty-format: 30.0.5 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)): + dependencies: + "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) + "@jest/test-result": 30.1.3 + "@jest/types": 30.0.5 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) + jest-util: 30.0.5 + jest-validate: 30.1.0 + yargs: 17.7.2 + transitivePeerDependencies: + - "@types/node" + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)): + dependencies: + "@babel/core": 7.28.3 + "@jest/get-type": 30.1.0 + "@jest/pattern": 30.0.1 + "@jest/test-sequencer": 30.1.3 + "@jest/types": 30.0.5 + babel-jest: 30.1.2(@babel/core@7.28.3) + chalk: 4.1.2 + ci-info: 4.3.0 + deepmerge: 4.3.1 + glob: 10.4.5 + graceful-fs: 4.2.11 + jest-circus: 30.1.3(babel-plugin-macros@3.1.0) + jest-docblock: 30.0.1 + jest-environment-node: 30.1.2 + jest-regex-util: 30.0.1 + jest-resolve: 30.1.3 + jest-runner: 30.1.3 + jest-util: 30.0.5 + jest-validate: 30.1.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.0.5 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + "@types/node": 24.2.1 + ts-node: 10.9.1(@types/node@24.2.1)(typescript@5.9.2) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.1.2: + dependencies: + "@jest/diff-sequences": 30.0.1 + "@jest/get-type": 30.1.0 + chalk: 4.1.2 + pretty-format: 30.0.5 + + jest-docblock@30.0.1: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.1.0: + dependencies: + "@jest/get-type": 30.1.0 + "@jest/types": 30.0.5 + chalk: 4.1.2 + jest-util: 30.0.5 + pretty-format: 30.0.5 + + jest-environment-node@30.1.2: + dependencies: + "@jest/environment": 30.1.2 + "@jest/fake-timers": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + jest-mock: 30.0.5 + jest-util: 30.0.5 + jest-validate: 30.1.0 + + jest-haste-map@30.1.0: + dependencies: + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.0.5 + jest-worker: 30.1.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.1.0: + dependencies: + "@jest/get-type": 30.1.0 + pretty-format: 30.0.5 + + jest-matcher-utils@30.1.2: + dependencies: + "@jest/get-type": 30.1.0 + chalk: 4.1.2 + jest-diff: 30.1.2 + pretty-format: 30.0.5 + + jest-message-util@30.1.0: + dependencies: + "@babel/code-frame": 7.27.1 + "@jest/types": 30.0.5 + "@types/stack-utils": 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.0.5 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.0.5: + dependencies: + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + jest-util: 30.0.5 + + jest-pnp-resolver@1.2.3(jest-resolve@30.1.3): + optionalDependencies: + jest-resolve: 30.1.3 + + jest-regex-util@30.0.1: {} + + jest-resolve-dependencies@30.1.3: + dependencies: + jest-regex-util: 30.0.1 + jest-snapshot: 30.1.2 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.1.3: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.1.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.1.3) + jest-util: 30.0.5 + jest-validate: 30.1.0 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.1.3: + dependencies: + "@jest/console": 30.1.2 + "@jest/environment": 30.1.2 + "@jest/test-result": 30.1.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.0.1 + jest-environment-node: 30.1.2 + jest-haste-map: 30.1.0 + jest-leak-detector: 30.1.0 + jest-message-util: 30.1.0 + jest-resolve: 30.1.3 + jest-runtime: 30.1.3 + jest-util: 30.0.5 + jest-watcher: 30.1.3 + jest-worker: 30.1.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.1.3: + dependencies: + "@jest/environment": 30.1.2 + "@jest/fake-timers": 30.1.2 + "@jest/globals": 30.1.2 + "@jest/source-map": 30.0.1 + "@jest/test-result": 30.1.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + cjs-module-lexer: 2.1.0 + collect-v8-coverage: 1.0.2 + glob: 10.4.5 + graceful-fs: 4.2.11 + jest-haste-map: 30.1.0 + jest-message-util: 30.1.0 + jest-mock: 30.0.5 + jest-regex-util: 30.0.1 + jest-resolve: 30.1.3 + jest-snapshot: 30.1.2 + jest-util: 30.0.5 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.1.2: + dependencies: + "@babel/core": 7.28.3 + "@babel/generator": 7.28.0 + "@babel/plugin-syntax-jsx": 7.27.1(@babel/core@7.28.3) + "@babel/plugin-syntax-typescript": 7.27.1(@babel/core@7.28.3) + "@babel/types": 7.28.2 + "@jest/expect-utils": 30.1.2 + "@jest/get-type": 30.1.0 + "@jest/snapshot-utils": 30.1.2 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.3) + chalk: 4.1.2 + expect: 30.1.2 + graceful-fs: 4.2.11 + jest-diff: 30.1.2 + jest-matcher-utils: 30.1.2 + jest-message-util: 30.1.0 + jest-util: 30.0.5 + pretty-format: 30.0.5 + semver: 7.7.2 + synckit: 0.11.11 + transitivePeerDependencies: + - supports-color + jest-util@29.7.0: dependencies: "@jest/types": 29.6.3 @@ -11152,6 +13598,41 @@ snapshots: graceful-fs: 4.2.11 picomatch: 2.3.1 + jest-util@30.0.5: + dependencies: + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + ci-info: 4.3.0 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + + jest-validate@30.1.0: + dependencies: + "@jest/get-type": 30.1.0 + "@jest/types": 30.0.5 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.0.5 + + jest-watcher@30.1.3: + dependencies: + "@jest/test-result": 30.1.3 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.0.5 + string-length: 4.0.2 + + jest-worker@27.5.1: + dependencies: + "@types/node": 24.3.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + jest-worker@29.7.0: dependencies: "@types/node": 24.2.1 @@ -11159,12 +13640,40 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest-worker@30.1.0: + dependencies: + "@types/node": 24.2.1 + "@ungap/structured-clone": 1.3.0 + jest-util: 30.0.5 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)): + dependencies: + "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) + "@jest/types": 30.0.5 + import-local: 3.2.0 + jest-cli: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) + transitivePeerDependencies: + - "@types/node" + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jiti@1.21.7: {} jose@4.15.9: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -11177,11 +13686,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-to-ts@1.6.4: - dependencies: - "@types/json-schema": 7.0.15 - ts-toolbelt: 6.15.5 - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -11192,15 +13696,7 @@ snapshots: dependencies: minimist: 1.2.8 - jsonfile@4.0.0: - optionalDependencies: - graceful-fs: 4.2.11 - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 + json5@2.2.3: {} jsx-ast-utils@3.3.5: dependencies: @@ -11219,15 +13715,13 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + leven@3.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - lie@3.1.1: - dependencies: - immediate: 3.0.6 - lighterhtml@4.2.0: dependencies: "@ungap/create-content": 0.2.0 @@ -11244,9 +13738,11 @@ snapshots: lines-and-columns@1.2.4: {} - localforage@1.10.0: + loader-runner@4.3.0: {} + + locate-path@5.0.0: dependencies: - lie: 3.1.1 + p-locate: 4.1.0 locate-path@6.0.0: dependencies: @@ -11256,6 +13752,8 @@ snapshots: lodash.isarguments@3.1.0: {} + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} longest-streak@3.1.0: {} @@ -11266,6 +13764,10 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -11274,16 +13776,24 @@ snapshots: dependencies: react: 18.3.1 - magic-string@0.27.0: + magic-string@0.30.19: dependencies: "@jridgewell/sourcemap-codec": 1.5.5 - make-dir@3.1.0: + magic-string@0.30.8: dependencies: - semver: 6.3.1 + "@jridgewell/sourcemap-codec": 1.5.5 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 make-error@1.3.6: {} + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + math-intrinsics@1.1.0: {} mdast-util-from-markdown@2.0.2: @@ -11395,12 +13905,6 @@ snapshots: merge2@1.4.1: {} - micro@9.3.5-canary.3: - dependencies: - arg: 4.1.0 - content-type: 1.0.4 - raw-body: 2.4.1 - micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.2.0 @@ -11555,52 +14059,23 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@8.0.4: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 minimist@1.2.8: {} - minipass@2.9.0: - dependencies: - safe-buffer: 5.2.1 - yallist: 3.1.1 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - - minipass@5.0.0: {} + minipass@4.2.8: {} minipass@7.1.2: {} - minizlib@1.3.3: - dependencies: - minipass: 2.9.0 - - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 - mitt@3.0.1: {} - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - - mkdirp@1.0.4: {} - - mlly@1.7.4: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.1 - - mri@1.2.0: {} - - ms@2.1.1: {} + module-details-from-path@1.0.4: {} ms@2.1.3: {} @@ -11618,13 +14093,13 @@ snapshots: neo-async@2.6.2: {} - next-auth@4.24.11(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.11(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: "@babel/runtime": 7.28.2 "@panva/hkdf": 1.2.1 cookie: 0.7.2 jose: 4.15.9 - next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + next: 15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) oauth: 0.9.15 openid-client: 5.7.1 preact: 10.27.0 @@ -11638,28 +14113,27 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0): + next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0): dependencies: - "@next/env": 14.2.31 - "@swc/helpers": 0.5.5 - busboy: 1.6.0 + "@next/env": 15.5.3 + "@swc/helpers": 0.5.15 caniuse-lite: 1.0.30001734 - graceful-fs: 4.2.11 postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1) optionalDependencies: - "@next/swc-darwin-arm64": 14.2.31 - "@next/swc-darwin-x64": 14.2.31 - "@next/swc-linux-arm64-gnu": 14.2.31 - "@next/swc-linux-arm64-musl": 14.2.31 - "@next/swc-linux-x64-gnu": 14.2.31 - "@next/swc-linux-x64-musl": 14.2.31 - "@next/swc-win32-arm64-msvc": 14.2.31 - "@next/swc-win32-ia32-msvc": 14.2.31 - "@next/swc-win32-x64-msvc": 14.2.31 + "@next/swc-darwin-arm64": 15.5.3 + "@next/swc-darwin-x64": 15.5.3 + "@next/swc-linux-arm64-gnu": 15.5.3 + "@next/swc-linux-arm64-musl": 15.5.3 + "@next/swc-linux-x64-gnu": 15.5.3 + "@next/swc-linux-x64-musl": 15.5.3 + "@next/swc-win32-arm64-msvc": 15.5.3 + "@next/swc-win32-x64-msvc": 15.5.3 + "@opentelemetry/api": 1.9.0 sass: 1.90.0 + sharp: 0.34.3 transitivePeerDependencies: - "@babel/core" - babel-plugin-macros @@ -11669,28 +14143,14 @@ snapshots: node-addon-api@7.1.1: optional: true - node-fetch-native@1.6.7: {} - - node-fetch@2.6.7: - dependencies: - whatwg-url: 5.0.0 - - node-fetch@2.6.9: - dependencies: - whatwg-url: 5.0.0 - node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 - node-gyp-build@4.8.4: {} + node-int64@0.4.0: {} node-releases@2.0.19: {} - nopt@5.0.0: - dependencies: - abbrev: 1.1.1 - normalize-path@3.0.0: {} normalize-range@0.1.2: {} @@ -11699,28 +14159,12 @@ snapshots: dependencies: path-key: 3.1.1 - npmlog@5.0.1: - dependencies: - are-we-there-yet: 2.0.0 - console-control-strings: 1.1.0 - gauge: 3.0.2 - set-blocking: 2.0.0 - - nuqs@2.4.3(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1): + nuqs@2.4.3(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1): dependencies: mitt: 3.0.1 react: 18.3.1 optionalDependencies: - next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) - - nypm@0.5.4: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - pathe: 2.0.3 - pkg-types: 1.3.1 - tinyexec: 0.3.2 - ufo: 1.6.1 + next: 15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) oauth@0.9.15: {} @@ -11770,14 +14214,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - ohash@1.1.6: {} - oidc-token-hash@5.1.1: {} - once@1.3.3: - dependencies: - wrappy: 1.0.2 - once@1.4.0: dependencies: wrappy: 1.0.2 @@ -11786,6 +14224,28 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openapi-fetch@0.14.0: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-react-query@0.5.0(@tanstack/react-query@5.85.9(react@18.3.1))(openapi-fetch@0.14.0): + dependencies: + "@tanstack/react-query": 5.85.9(react@18.3.1) + openapi-fetch: 0.14.0 + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.9.1(typescript@5.9.2): + dependencies: + "@redocly/openapi-core": 1.34.5(supports-color@10.2.0) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.0 + typescript: 5.9.2 + yargs-parser: 21.1.1 + openid-client@5.7.1: dependencies: jose: 4.15.9 @@ -11802,24 +14262,30 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - os-paths@4.4.0: {} - own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 object-keys: 1.1.1 safe-push-apply: 1.0.0 - p-finally@2.0.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -11843,9 +14309,11 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 - parse-ms@2.1.0: {} - - path-browserify@1.0.1: {} + parse-json@8.3.0: + dependencies: + "@babel/code-frame": 7.27.1 + index-to-position: 1.1.0 + type-fest: 4.41.0 path-exists@4.0.0: {} @@ -11853,11 +14321,6 @@ snapshots: path-key@3.1.1: {} - path-match@1.2.4: - dependencies: - http-errors: 1.4.0 - path-to-regexp: 1.9.0 - path-parse@1.0.7: {} path-scurry@1.11.1: @@ -11865,27 +14328,21 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@1.9.0: - dependencies: - isarray: 0.0.1 - - path-to-regexp@6.1.0: {} - - path-to-regexp@6.2.1: {} - path-type@4.0.0: {} - pathe@1.1.2: {} - - pathe@2.0.3: {} - - pend@1.2.0: {} - - perfect-debounce@1.0.0: {} - perfect-freehand@1.2.2: {} - picocolors@1.0.0: {} + pg-int8@1.0.1: {} + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 picocolors@1.1.1: {} @@ -11897,11 +14354,11 @@ snapshots: pirates@4.0.7: {} - pkg-types@1.3.1: + pkg-dir@4.2.0: dependencies: - confbox: 0.1.8 - mlly: 1.7.4 - pathe: 2.0.3 + find-up: 4.1.0 + + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} @@ -11917,13 +14374,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)): + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)): dependencies: lilconfig: 3.1.3 yaml: 2.8.1 optionalDependencies: postcss: 8.5.6 - ts-node: 10.9.1(@types/node@16.18.11)(typescript@5.9.2) + ts-node: 10.9.1(@types/node@24.2.1)(typescript@5.9.2) postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -11949,6 +14406,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + preact-render-to-string@5.2.6(preact@10.27.0): dependencies: preact: 10.27.0 @@ -11962,14 +14429,14 @@ snapshots: pretty-format@3.8.0: {} - pretty-ms@7.0.1: + pretty-format@30.0.5: dependencies: - parse-ms: 2.1.0 + "@jest/schemas": 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 progress@2.0.3: {} - promisepipe@3.0.0: {} - prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -11986,13 +14453,10 @@ snapshots: dependencies: proxy-compare: 3.0.1 - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode@2.3.1: {} + pure-rand@7.0.1: {} + qr.js@0.0.0: {} queue-microtask@1.2.3: {} @@ -12001,18 +14465,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - raw-body@2.4.1: - dependencies: - bytes: 3.1.0 - http-errors: 1.7.3 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - - rc9@2.1.2: - dependencies: - defu: 6.1.4 - destr: 2.0.5 - react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -12031,6 +14483,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react-markdown@9.1.0(@types/react@18.2.20)(react@18.3.1): dependencies: "@types/hast": 3.0.4 @@ -12102,10 +14556,6 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - readdirp@3.3.0: - dependencies: - picomatch: 2.3.1 - readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -12165,12 +14615,30 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remeda@2.31.1: + dependencies: + type-fest: 4.41.0 + + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.1(supports-color@9.4.0) + module-details-from-path: 1.0.4 + resolve: 1.22.10 + transitivePeerDependencies: + - supports-color + reraf@1.1.1: {} reselect@5.1.1: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -12197,12 +14665,31 @@ snapshots: reusify@1.1.0: {} - rimraf@3.0.2: + rollup@4.50.1: dependencies: - glob: 7.2.3 - - rollup@2.79.2: + "@types/estree": 1.0.8 optionalDependencies: + "@rollup/rollup-android-arm-eabi": 4.50.1 + "@rollup/rollup-android-arm64": 4.50.1 + "@rollup/rollup-darwin-arm64": 4.50.1 + "@rollup/rollup-darwin-x64": 4.50.1 + "@rollup/rollup-freebsd-arm64": 4.50.1 + "@rollup/rollup-freebsd-x64": 4.50.1 + "@rollup/rollup-linux-arm-gnueabihf": 4.50.1 + "@rollup/rollup-linux-arm-musleabihf": 4.50.1 + "@rollup/rollup-linux-arm64-gnu": 4.50.1 + "@rollup/rollup-linux-arm64-musl": 4.50.1 + "@rollup/rollup-linux-loongarch64-gnu": 4.50.1 + "@rollup/rollup-linux-ppc64-gnu": 4.50.1 + "@rollup/rollup-linux-riscv64-gnu": 4.50.1 + "@rollup/rollup-linux-riscv64-musl": 4.50.1 + "@rollup/rollup-linux-s390x-gnu": 4.50.1 + "@rollup/rollup-linux-x64-gnu": 4.50.1 + "@rollup/rollup-linux-x64-musl": 4.50.1 + "@rollup/rollup-openharmony-arm64": 4.50.1 + "@rollup/rollup-win32-arm64-msvc": 4.50.1 + "@rollup/rollup-win32-ia32-msvc": 4.50.1 + "@rollup/rollup-win32-x64-msvc": 4.50.1 fsevents: 2.3.3 rtcstats@https://codeload.github.com/whereby/rtcstats/tar.gz/63bcb6420d76d34161b39e494524ae73aa6dd70d: @@ -12235,8 +14722,6 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - safer-buffer@2.1.2: {} - sass@1.90.0: dependencies: chokidar: 4.0.3 @@ -12249,19 +14734,24 @@ snapshots: dependencies: loose-envify: 1.4.0 + schema-utils@4.3.2: + dependencies: + "@types/json-schema": 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + sdp-transform@2.15.0: {} sdp@3.2.1: {} semver@6.3.1: {} - semver@7.3.5: - dependencies: - lru-cache: 6.0.0 - semver@7.7.2: {} - set-blocking@2.0.0: {} + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 set-function-length@1.2.2: dependencies: @@ -12285,7 +14775,35 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - setprototypeof@1.1.1: {} + sharp@0.34.3: + dependencies: + color: 4.2.3 + detect-libc: 2.0.4 + semver: 7.7.2 + optionalDependencies: + "@img/sharp-darwin-arm64": 0.34.3 + "@img/sharp-darwin-x64": 0.34.3 + "@img/sharp-libvips-darwin-arm64": 1.2.0 + "@img/sharp-libvips-darwin-x64": 1.2.0 + "@img/sharp-libvips-linux-arm": 1.2.0 + "@img/sharp-libvips-linux-arm64": 1.2.0 + "@img/sharp-libvips-linux-ppc64": 1.2.0 + "@img/sharp-libvips-linux-s390x": 1.2.0 + "@img/sharp-libvips-linux-x64": 1.2.0 + "@img/sharp-libvips-linuxmusl-arm64": 1.2.0 + "@img/sharp-libvips-linuxmusl-x64": 1.2.0 + "@img/sharp-linux-arm": 0.34.3 + "@img/sharp-linux-arm64": 0.34.3 + "@img/sharp-linux-ppc64": 0.34.3 + "@img/sharp-linux-s390x": 0.34.3 + "@img/sharp-linux-x64": 0.34.3 + "@img/sharp-linuxmusl-arm64": 0.34.3 + "@img/sharp-linuxmusl-x64": 0.34.3 + "@img/sharp-wasm32": 0.34.3 + "@img/sharp-win32-arm64": 0.34.3 + "@img/sharp-win32-ia32": 0.34.3 + "@img/sharp-win32-x64": 0.34.3 + optional: true shebang-command@2.0.0: dependencies: @@ -12293,6 +14811,8 @@ snapshots: shebang-regex@3.0.0: {} + shimmer@1.2.1: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -12323,8 +14843,6 @@ snapshots: signal-exit@3.0.7: {} - signal-exit@4.0.2: {} - signal-exit@4.1.0: {} simple-peer@9.11.1: @@ -12339,6 +14857,13 @@ snapshots: transitivePeerDependencies: - supports-color + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + optional: true + + slash@3.0.0: {} + socket.io-client@4.7.2: dependencies: "@socket.io/component-emitter": 3.1.2 @@ -12359,42 +14884,47 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.5.7: {} source-map@0.6.1: {} space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} stable-hash@0.0.5: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stacktrace-parser@0.1.11: dependencies: type-fest: 0.7.1 standard-as-callback@2.1.0: {} - stat-mode@0.3.0: {} - - statuses@1.5.0: {} - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 - stream-to-array@2.3.0: + string-length@4.0.2: dependencies: - any-promise: 1.3.0 - - stream-to-promise@2.2.0: - dependencies: - any-promise: 1.3.0 - end-of-stream: 1.1.0 - stream-to-array: 2.3.0 - - streamsearch@1.1.0: {} + char-regex: 1.0.2 + strip-ansi: 6.0.1 string-width@4.2.3: dependencies: @@ -12477,6 +15007,8 @@ snapshots: strip-bom@3.0.0: {} + strip-bom@4.0.0: {} + strip-final-newline@2.0.0: {} strip-json-comments@3.1.1: {} @@ -12489,10 +15021,13 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.1(react@18.3.1): + styled-jsx@5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 + optionalDependencies: + "@babel/core": 7.28.3 + babel-plugin-macros: 3.1.0 stylis@4.2.0: {} @@ -12506,6 +15041,8 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 + supports-color@10.2.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -12518,7 +15055,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tailwindcss@3.4.17(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)): + synckit@0.11.11: + dependencies: + "@pkgr/core": 0.2.9 + + tailwindcss@3.4.17(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)): dependencies: "@alloc/quick-lru": 5.2.0 arg: 5.0.2 @@ -12537,7 +15078,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.0.1(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -12545,24 +15086,29 @@ snapshots: transitivePeerDependencies: - ts-node - tar@4.4.18: - dependencies: - chownr: 1.1.4 - fs-minipass: 1.2.7 - minipass: 2.9.0 - minizlib: 1.3.3 - mkdirp: 0.5.6 - safe-buffer: 5.2.1 - yallist: 3.1.1 + tapable@2.2.3: {} - tar@6.2.1: + terser-webpack-plugin@5.3.14(webpack@5.101.3): dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 + "@jridgewell/trace-mapping": 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.2 + serialize-javascript: 6.0.2 + terser: 5.44.0 + webpack: 5.101.3 + + terser@5.44.0: + dependencies: + "@jridgewell/source-map": 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + "@istanbuljs/schema": 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 thenify-all@1.6.0: dependencies: @@ -12572,27 +15118,19 @@ snapshots: dependencies: any-promise: 1.3.0 - time-span@4.0.0: - dependencies: - convert-hrtime: 3.0.0 - - tinyexec@0.3.2: {} - tinyglobby@0.2.14: dependencies: fdir: 6.4.6(picomatch@4.0.3) picomatch: 4.0.3 + tmpl@1.0.5: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - toidentifier@1.0.0: {} - tr46@0.0.3: {} - tree-kill@1.2.2: {} - trim-lines@3.0.1: {} trough@2.2.0: {} @@ -12603,37 +15141,34 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-morph@12.0.0: + ts-jest@29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)))(typescript@5.9.2): dependencies: - "@ts-morph/common": 0.11.1 - code-block-writer: 10.1.1 - - ts-node@10.9.1(@types/node@16.18.11)(typescript@4.9.5): - dependencies: - "@cspotcode/source-map-support": 0.8.1 - "@tsconfig/node10": 1.0.11 - "@tsconfig/node12": 1.0.11 - "@tsconfig/node14": 1.0.3 - "@tsconfig/node16": 1.0.4 - "@types/node": 16.18.11 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)) + json5: 2.2.3 + lodash.memoize: 4.1.2 make-error: 1.3.6 - typescript: 4.9.5 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 + semver: 7.7.2 + type-fest: 4.41.0 + typescript: 5.9.2 + yargs-parser: 21.1.1 + optionalDependencies: + "@babel/core": 7.28.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + babel-jest: 30.1.2(@babel/core@7.28.3) + jest-util: 30.0.5 - ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2): + ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2): dependencies: "@cspotcode/source-map-support": 0.8.1 "@tsconfig/node10": 1.0.11 "@tsconfig/node12": 1.0.11 "@tsconfig/node14": 1.0.3 "@tsconfig/node16": 1.0.4 - "@types/node": 16.18.11 + "@types/node": 24.2.1 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -12645,8 +15180,6 @@ snapshots: yn: 3.1.1 optional: true - ts-toolbelt@6.15.5: {} - tsconfig-paths@3.15.0: dependencies: "@types/json5": 0.0.29 @@ -12660,8 +15193,14 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + type-fest@0.7.1: {} + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -12695,8 +15234,6 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript@4.9.5: {} - typescript@5.9.2: {} ua-is-frozen@0.1.2: {} @@ -12717,8 +15254,6 @@ snapshots: udomdiff@1.1.2: {} - ufo@1.6.1: {} - uglify-js@3.19.3: optional: true @@ -12728,8 +15263,6 @@ snapshots: uhyphen@0.1.0: {} - uid-promise@1.0.0: {} - umap@1.0.2: {} unbox-primitive@1.1.0: @@ -12739,14 +15272,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - uncrypto@0.1.3: {} - undici-types@7.10.0: {} - undici@5.28.4: - dependencies: - "@fastify/busboy": 2.1.1 - unified@11.0.5: dependencies: "@types/unist": 3.0.3 @@ -12780,11 +15307,12 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 - universalify@0.1.2: {} - - universalify@2.0.1: {} - - unpipe@1.0.0: {} + unplugin@1.0.1: + dependencies: + acorn: 8.15.0 + chokidar: 3.6.0 + webpack-sources: 3.3.3 + webpack-virtual-modules: 0.5.0 unrs-resolver@1.11.1: dependencies: @@ -12818,6 +15346,8 @@ snapshots: uqr@0.1.2: {} + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -12841,8 +15371,6 @@ snapshots: uuid-validate@0.0.3: {} - uuid@3.3.2: {} - uuid@8.3.2: {} uuid@9.0.1: {} @@ -12851,27 +15379,14 @@ snapshots: dependencies: uarray: 1.0.0 - v8-compile-cache-lib@3.0.1: {} + v8-compile-cache-lib@3.0.1: + optional: true - vercel@37.14.0: + v8-to-istanbul@9.3.0: dependencies: - "@vercel/build-utils": 8.4.12 - "@vercel/fun": 1.1.0 - "@vercel/go": 3.2.0 - "@vercel/hydrogen": 1.0.9 - "@vercel/next": 4.3.18 - "@vercel/node": 3.2.24 - "@vercel/python": 4.3.1 - "@vercel/redwood": 2.1.8 - "@vercel/remix-builder": 2.2.13 - "@vercel/ruby": 2.1.0 - "@vercel/static-build": 2.5.34 - chokidar: 3.3.1 - transitivePeerDependencies: - - "@swc/core" - - "@swc/wasm" - - encoding - - supports-color + "@jridgewell/trace-mapping": 0.3.30 + "@types/istanbul-lib-coverage": 2.0.6 + convert-source-map: 2.0.0 vfile-message@4.0.3: dependencies: @@ -12883,14 +15398,55 @@ snapshots: "@types/unist": 3.0.3 vfile-message: 4.0.3 - wavesurfer.js@7.10.1: {} + walker@1.0.8: + dependencies: + makeerror: 1.0.12 - web-vitals@0.2.4: {} + watchpack@2.4.4: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wavesurfer.js@7.10.1: {} webidl-conversions@3.0.1: {} webpack-sources@3.3.3: {} + webpack-virtual-modules@0.5.0: {} + + webpack@5.101.3: + dependencies: + "@types/eslint-scope": 3.7.7 + "@types/estree": 1.0.8 + "@types/json-schema": 7.0.15 + "@webassemblyjs/ast": 1.14.1 + "@webassemblyjs/wasm-edit": 1.14.1 + "@webassemblyjs/wasm-parser": 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.25.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.2 + tapable: 2.2.3 + terser-webpack-plugin: 5.3.14(webpack@5.101.3) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - "@swc/core" + - esbuild + - uglify-js + webrtc-adapter@9.0.3: dependencies: sdp: 3.2.1 @@ -12945,10 +15501,6 @@ snapshots: dependencies: isexe: 2.0.0 - wide-align@1.1.5: - dependencies: - string-width: 4.2.3 - word-wrap@1.2.5: {} wordwrap@1.0.0: {} @@ -12967,42 +15519,46 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + ws@8.17.1: {} - xdg-app-paths@5.1.0: - dependencies: - xdg-portable: 7.3.0 - - xdg-portable@7.3.0: - dependencies: - os-paths: 4.4.0 - xmlhttprequest-ssl@2.0.0: {} + xtend@4.0.2: {} + + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@4.0.0: {} + yaml-ast-parser@0.0.43: {} + yaml@1.10.2: {} yaml@2.8.1: {} - yauzl-clone@1.0.4: - dependencies: - events-intercept: 2.0.0 + yargs-parser@21.1.1: {} - yauzl-promise@2.1.3: + yargs@17.7.2: dependencies: - yauzl: 2.10.0 - yauzl-clone: 1.0.4 + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - - yn@3.1.1: {} + yn@3.1.1: + optional: true yocto-queue@0.1.0: {} + zod@4.1.5: {} + zwitch@2.0.4: {} diff --git a/www/public/service-worker.js b/www/public/service-worker.js index 109561d5..e798e369 100644 --- a/www/public/service-worker.js +++ b/www/public/service-worker.js @@ -1,4 +1,4 @@ -let authToken = ""; // Variable to store the token +let authToken = null; self.addEventListener("message", (event) => { if (event.data && event.data.type === "SET_AUTH_TOKEN") {