mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-23 21:59:06 +00:00
Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8598707c1c | |||
|
|
594bcc09e0 | ||
| 7c2d0698ed | |||
|
|
1dac999b56 | ||
|
|
f580b996ee | ||
|
|
225783496f | ||
| f0ee7b531a | |||
| 37a454f283 | |||
| 964cd78bb6 | |||
| 5f458aa4a7 | |||
| 5f7dfadabd | |||
| 0bc971ba96 | |||
|
|
c62e3c0753 | ||
|
|
16284e1ac3 | ||
|
|
443982617d | ||
|
|
23023b3cdb | ||
| 90c3ecc9c3 | |||
| d7f140b7d1 | |||
| a47a5f5781 | |||
| 0eba147018 | |||
| 18a27f7b45 | |||
| 32a049c134 | |||
| 91650ec65f | |||
|
|
61f0e29d4c | ||
|
|
ec17ed7b58 | ||
|
|
00549f153a | ||
| 3ad78be762 | |||
| d3a5cd12d2 | |||
| af921ce927 | |||
|
|
bd5df1ce2e | ||
| c8024484b3 | |||
| 28f87c09dc | |||
| dabf7251db | |||
|
|
b51b7aa917 | ||
|
|
a8983b4e7e | ||
|
|
fe47c46489 | ||
| a2bb6a27d6 | |||
| 7f0b728991 | |||
| 692895c859 | |||
|
|
d63040e2fd | ||
| 8d696aa775 | |||
| f6ca07505f | |||
|
|
3aef926203 | ||
|
|
0b2c82227d | ||
|
|
689c8075cc | ||
| 201671368a | |||
|
|
86d5e26224 | ||
| 9bec39808f | |||
| 86ac23868b | |||
|
|
c442a62787 | ||
|
|
8e438ca285 | ||
|
|
11731c9d38 | ||
|
|
4287f8b8ae | ||
| 3e47c2c057 | |||
|
|
616092a9bb | ||
| 18ed713369 | |||
| 2801ab3643 | |||
|
|
b20cad76e6 | ||
| 28a7258e45 | |||
| a9a4f32324 | |||
|
|
857e035562 | ||
| 34a3f5618c | |||
|
|
1473fd82dc | ||
| 372202b0e1 | |||
|
|
d20aac66c4 | ||
| dc4b737daa | |||
|
|
0baff7abf7 | ||
|
|
962c40e2b6 | ||
|
|
3c4b9f2103 | ||
|
|
c6c035aacf | ||
| c086b91445 | |||
|
|
9a258abc02 | ||
| af86c47f1d | |||
| 5f6910e513 | |||
| 9a71af145e | |||
| eef6dc3903 | |||
|
|
1dee255fed | ||
| 5d98754305 | |||
|
|
969bd84fcc | ||
|
|
36608849ec | ||
|
|
5bf64b5a41 | ||
| 0aaa42528a | |||
| 565a62900f | |||
|
|
27016e6051 | ||
| 6ddfee0b4e | |||
|
|
47716f6e5d | ||
| 0abcebfc94 | |||
|
|
2b723da08b | ||
| 6566e04300 | |||
| 870e860517 | |||
| 396a95d5ce | |||
| 6f680b5795 | |||
| ab859d65a6 | |||
| fa049e8d06 | |||
| 2ce7479967 | |||
| b42f7cfc60 | |||
| c546e69739 | |||
|
|
3f1fe8c9bf | ||
| 5f143fe364 | |||
|
|
79f161436e | ||
|
|
5cba5d310d | ||
| 43ea9349f5 | |||
|
|
b3a8e9739d | ||
|
|
369ecdff13 | ||
| fc363bd49b | |||
|
|
962038ee3f | ||
|
|
3b85ff3bdf | ||
|
|
cde99ca271 | ||
|
|
f81fe9948a | ||
|
|
5a5b323382 | ||
| 02a3938822 | |||
|
|
7f5a4c9ddc | ||
|
|
08d88ec349 | ||
|
|
c4d2825c81 | ||
| 0663700a61 | |||
| dc82f8bb3b | |||
| 457823e1c1 | |||
|
|
695d1a957d | ||
| ccffdba75b | |||
| 84a381220b | |||
| 5f2f0e9317 | |||
| 88ed7cfa78 | |||
| 6f0c7c1a5e | |||
| 9dfd76996f | |||
| 55cc8637c6 | |||
| f5331a2107 | |||
|
|
124ce03bf8 | ||
| 7030e0f236 | |||
| 37f0110892 | |||
| cf2896a7f4 | |||
| aabf2c2572 | |||
| 6a7b08f016 | |||
| e2736563d9 | |||
| 0f54b7782d | |||
| 359280dd34 | |||
| 9265d201b5 | |||
| 52f9f533d7 | |||
| 0c3878ac3c | |||
|
|
d70beee51b |
5
.github/workflows/db_migrations.yml
vendored
5
.github/workflows/db_migrations.yml
vendored
@@ -2,6 +2,8 @@ name: Test Database Migrations
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
paths:
|
paths:
|
||||||
- "server/migrations/**"
|
- "server/migrations/**"
|
||||||
- "server/reflector/db/**"
|
- "server/reflector/db/**"
|
||||||
@@ -17,6 +19,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
test-migrations:
|
test-migrations:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: db-ubuntu-latest-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
|
|||||||
90
.github/workflows/deploy.yml
vendored
90
.github/workflows/deploy.yml
vendored
@@ -1,90 +0,0 @@
|
|||||||
name: Deploy to Amazon ECS
|
|
||||||
|
|
||||||
on: [workflow_dispatch]
|
|
||||||
|
|
||||||
env:
|
|
||||||
# 950402358378.dkr.ecr.us-east-1.amazonaws.com/reflector
|
|
||||||
AWS_REGION: us-east-1
|
|
||||||
ECR_REPOSITORY: reflector
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: linux-amd64
|
|
||||||
arch: amd64
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: linux-arm64
|
|
||||||
arch: arm64
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
registry: ${{ steps.login-ecr.outputs.registry }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
|
||||||
with:
|
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
aws-region: ${{ env.AWS_REGION }}
|
|
||||||
|
|
||||||
- name: Login to Amazon ECR
|
|
||||||
id: login-ecr
|
|
||||||
uses: aws-actions/amazon-ecr-login@v2
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build and push ${{ matrix.arch }}
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: server
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest-${{ matrix.arch }}
|
|
||||||
cache-from: type=gha,scope=${{ matrix.arch }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
|
|
||||||
github-token: ${{ secrets.GHA_CACHE_TOKEN }}
|
|
||||||
provenance: false
|
|
||||||
|
|
||||||
create-manifest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
deployments: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Configure AWS credentials
|
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
|
||||||
with:
|
|
||||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
aws-region: ${{ env.AWS_REGION }}
|
|
||||||
|
|
||||||
- name: Login to Amazon ECR
|
|
||||||
uses: aws-actions/amazon-ecr-login@v2
|
|
||||||
|
|
||||||
- name: Create and push multi-arch manifest
|
|
||||||
run: |
|
|
||||||
# Get the registry URL (since we can't easily access job outputs in matrix)
|
|
||||||
ECR_REGISTRY=$(aws ecr describe-registry --query 'registryId' --output text).dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com
|
|
||||||
|
|
||||||
docker manifest create \
|
|
||||||
$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest \
|
|
||||||
$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest-amd64 \
|
|
||||||
$ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest-arm64
|
|
||||||
|
|
||||||
docker manifest push $ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest
|
|
||||||
|
|
||||||
echo "✅ Multi-arch manifest pushed: $ECR_REGISTRY/${{ env.ECR_REPOSITORY }}:latest"
|
|
||||||
53
.github/workflows/dockerhub-backend.yml
vendored
Normal file
53
.github/workflows/dockerhub-backend.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: Build and Push Backend Docker Image (Docker Hub)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.io
|
||||||
|
IMAGE_NAME: monadicalsas/reflector-backend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: monadicalsas
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
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: ./server
|
||||||
|
file: ./server/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
|
||||||
70
.github/workflows/dockerhub-frontend.yml
vendored
Normal file
70
.github/workflows/dockerhub-frontend.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Build and Push Frontend Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: docker.io
|
||||||
|
IMAGE_NAME: monadicalsas/reflector-frontend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: monadicalsas
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build-and-push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: success()
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
environment: [reflector-monadical, reflector-media]
|
||||||
|
environment: ${{ matrix.environment }}
|
||||||
|
steps:
|
||||||
|
- name: Trigger Coolify deployment
|
||||||
|
run: |
|
||||||
|
curl -X POST "${{ secrets.COOLIFY_WEBHOOK_URL }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.COOLIFY_WEBHOOK_TOKEN }}" \
|
||||||
|
-f || (echo "Failed to trigger Coolify deployment for ${{ matrix.environment }}" && exit 1)
|
||||||
45
.github/workflows/test_next_server.yml
vendored
Normal file
45
.github/workflows/test_next_server.yml
vendored
Normal file
@@ -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
|
||||||
11
.github/workflows/test_server.yml
vendored
11
.github/workflows/test_server.yml
vendored
@@ -5,12 +5,17 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "server/**"
|
- "server/**"
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
paths:
|
paths:
|
||||||
- "server/**"
|
- "server/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
concurrency:
|
||||||
|
group: pytest-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:6
|
image: redis:6
|
||||||
@@ -30,6 +35,9 @@ jobs:
|
|||||||
|
|
||||||
docker-amd64:
|
docker-amd64:
|
||||||
runs-on: linux-amd64
|
runs-on: linux-amd64
|
||||||
|
concurrency:
|
||||||
|
group: docker-amd64-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
@@ -45,6 +53,9 @@ jobs:
|
|||||||
|
|
||||||
docker-arm64:
|
docker-arm64:
|
||||||
runs-on: linux-arm64
|
runs-on: linux-arm64
|
||||||
|
concurrency:
|
||||||
|
group: docker-arm64-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -15,3 +15,7 @@ www/REFACTOR.md
|
|||||||
www/reload-frontend
|
www/reload-frontend
|
||||||
server/test.sqlite
|
server/test.sqlite
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
www/.env.development
|
||||||
|
www/.env.production
|
||||||
|
.playwright-mcp
|
||||||
|
.secrets
|
||||||
|
|||||||
1
.gitleaksignore
Normal file
1
.gitleaksignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
b9d891d3424f371642cb032ecfd0e2564470a72c:server/tests/test_transcripts_recording_deletion.py:generic-api-key:15
|
||||||
@@ -27,3 +27,8 @@ repos:
|
|||||||
files: ^server/
|
files: ^server/
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
files: ^server/
|
files: ^server/
|
||||||
|
|
||||||
|
- repo: https://github.com/gitleaks/gitleaks
|
||||||
|
rev: v8.28.0
|
||||||
|
hooks:
|
||||||
|
- id: gitleaks
|
||||||
|
|||||||
24
.secrets.example
Normal file
24
.secrets.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Example secrets file for GitHub Actions workflows
|
||||||
|
# Copy this to .secrets and fill in your values
|
||||||
|
# These secrets should be configured in GitHub repository settings:
|
||||||
|
# Settings > Secrets and variables > Actions
|
||||||
|
|
||||||
|
# DockerHub Configuration (required for frontend and backend deployment)
|
||||||
|
# Create a Docker Hub access token at https://hub.docker.com/settings/security
|
||||||
|
# Username: monadicalsas
|
||||||
|
DOCKERHUB_TOKEN=your-dockerhub-access-token
|
||||||
|
|
||||||
|
# GitHub Token (required for frontend and backend deployment)
|
||||||
|
# Used by docker/metadata-action for extracting image metadata
|
||||||
|
# Can use the default GITHUB_TOKEN or create a personal access token
|
||||||
|
GITHUB_TOKEN=your-github-token-or-use-default-GITHUB_TOKEN
|
||||||
|
|
||||||
|
# Coolify Deployment Webhook (required for frontend deployment)
|
||||||
|
# Used to trigger automatic deployment after image push
|
||||||
|
# Configure these secrets in GitHub Environments:
|
||||||
|
# Each environment should have:
|
||||||
|
# - COOLIFY_WEBHOOK_URL: The webhook URL for that specific deployment
|
||||||
|
# - COOLIFY_WEBHOOK_TOKEN: The webhook token (can be the same for both if using same token)
|
||||||
|
|
||||||
|
# Optional: GitHub Actions Cache Token (for local testing with act)
|
||||||
|
GHA_CACHE_TOKEN=your-github-token-or-empty
|
||||||
314
CHANGELOG.md
314
CHANGELOG.md
@@ -1,5 +1,319 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.26.0](https://github.com/Monadical-SAS/reflector/compare/v0.25.0...v0.26.0) (2025-12-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* parallelize hatchet ([#804](https://github.com/Monadical-SAS/reflector/issues/804)) ([594bcc0](https://github.com/Monadical-SAS/reflector/commit/594bcc09e0ca744163de2f1525ebbf7c52a68448))
|
||||||
|
|
||||||
|
## [0.25.0](https://github.com/Monadical-SAS/reflector/compare/v0.24.0...v0.25.0) (2025-12-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* consent disable feature ([#799](https://github.com/Monadical-SAS/reflector/issues/799)) ([2257834](https://github.com/Monadical-SAS/reflector/commit/225783496f2e265d5cb58e3539a20bf6b55589b8))
|
||||||
|
* durable ([#794](https://github.com/Monadical-SAS/reflector/issues/794)) ([1dac999](https://github.com/Monadical-SAS/reflector/commit/1dac999b56997582ce400e7d56e915adc1e4728d))
|
||||||
|
* increase daily recording max duration ([#801](https://github.com/Monadical-SAS/reflector/issues/801)) ([f580b99](https://github.com/Monadical-SAS/reflector/commit/f580b996eef49cce16433c505abfc6454dd45de1))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* logout redirect ([#802](https://github.com/Monadical-SAS/reflector/issues/802)) ([f0ee7b5](https://github.com/Monadical-SAS/reflector/commit/f0ee7b531a0911f214ccbb84d399e9a6c9b700c0))
|
||||||
|
|
||||||
|
## [0.24.0](https://github.com/Monadical-SAS/reflector/compare/v0.23.2...v0.24.0) (2025-12-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* identify action items ([#790](https://github.com/Monadical-SAS/reflector/issues/790)) ([964cd78](https://github.com/Monadical-SAS/reflector/commit/964cd78bb699d83d012ae4b8c96565df25b90a5d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* automatically reprocess daily recordings ([#797](https://github.com/Monadical-SAS/reflector/issues/797)) ([5f458aa](https://github.com/Monadical-SAS/reflector/commit/5f458aa4a7ec3d00ca5ec49d62fcc8ad232b138e))
|
||||||
|
* daily video optimisation ([#789](https://github.com/Monadical-SAS/reflector/issues/789)) ([16284e1](https://github.com/Monadical-SAS/reflector/commit/16284e1ac3faede2b74f0d91b50c0b5612af2c35))
|
||||||
|
* main menu login ([#800](https://github.com/Monadical-SAS/reflector/issues/800)) ([0bc971b](https://github.com/Monadical-SAS/reflector/commit/0bc971ba966a52d719c8c240b47dc7b3bdea4391))
|
||||||
|
* retry on workflow timeout ([#798](https://github.com/Monadical-SAS/reflector/issues/798)) ([5f7dfad](https://github.com/Monadical-SAS/reflector/commit/5f7dfadabd3e8017406ad3720ba495a59963ee34))
|
||||||
|
|
||||||
|
## [0.23.2](https://github.com/Monadical-SAS/reflector/compare/v0.23.1...v0.23.2) (2025-12-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* build on push tags ([#785](https://github.com/Monadical-SAS/reflector/issues/785)) ([d7f140b](https://github.com/Monadical-SAS/reflector/commit/d7f140b7d1f4660d5da7a0da1357f68869e0b5cd))
|
||||||
|
|
||||||
|
## [0.23.1](https://github.com/Monadical-SAS/reflector/compare/v0.23.0...v0.23.1) (2025-12-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* populate room_name in transcript GET endpoint ([#783](https://github.com/Monadical-SAS/reflector/issues/783)) ([0eba147](https://github.com/Monadical-SAS/reflector/commit/0eba1470181c7b9e0a79964a1ef28c09bcbdd9d7))
|
||||||
|
|
||||||
|
## [0.23.0](https://github.com/Monadical-SAS/reflector/compare/v0.22.4...v0.23.0) (2025-12-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* dockerhub ci ([#772](https://github.com/Monadical-SAS/reflector/issues/772)) ([00549f1](https://github.com/Monadical-SAS/reflector/commit/00549f153ade922cf4cb6c5358a7d11a39c426d2))
|
||||||
|
* llm retries ([#739](https://github.com/Monadical-SAS/reflector/issues/739)) ([61f0e29](https://github.com/Monadical-SAS/reflector/commit/61f0e29d4c51eab54ee67af92141fbb171e8ccaa))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* celery inspect bug sidestep in restart script ([#766](https://github.com/Monadical-SAS/reflector/issues/766)) ([ec17ed7](https://github.com/Monadical-SAS/reflector/commit/ec17ed7b587cf6ee143646baaee67a7c017044d4))
|
||||||
|
* deploy frontend to coolify ([#779](https://github.com/Monadical-SAS/reflector/issues/779)) ([91650ec](https://github.com/Monadical-SAS/reflector/commit/91650ec65f65713faa7ee0dcfb75af427b7c4ba0))
|
||||||
|
* hide rooms settings instead of disabling ([#763](https://github.com/Monadical-SAS/reflector/issues/763)) ([3ad78be](https://github.com/Monadical-SAS/reflector/commit/3ad78be7628c0d029296b301a0e87236c76b7598))
|
||||||
|
* return participant emails from transcript endpoint ([#769](https://github.com/Monadical-SAS/reflector/issues/769)) ([d3a5cd1](https://github.com/Monadical-SAS/reflector/commit/d3a5cd12d2d0d9c32af2d5bd9322e030ef69b85d))
|
||||||
|
|
||||||
|
## [0.22.4](https://github.com/Monadical-SAS/reflector/compare/v0.22.3...v0.22.4) (2025-12-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Multitrack mixdown optimisation 2 ([#764](https://github.com/Monadical-SAS/reflector/issues/764)) ([bd5df1c](https://github.com/Monadical-SAS/reflector/commit/bd5df1ce2ebf35d7f3413b295e56937a9a28ef7b))
|
||||||
|
|
||||||
|
## [0.22.3](https://github.com/Monadical-SAS/reflector/compare/v0.22.2...v0.22.3) (2025-12-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align daily room settings ([#759](https://github.com/Monadical-SAS/reflector/issues/759)) ([28f87c0](https://github.com/Monadical-SAS/reflector/commit/28f87c09dc459846873d0dde65b03e3d7b2b9399))
|
||||||
|
|
||||||
|
## [0.22.2](https://github.com/Monadical-SAS/reflector/compare/v0.22.1...v0.22.2) (2025-12-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* daily auto refresh fix ([#755](https://github.com/Monadical-SAS/reflector/issues/755)) ([fe47c46](https://github.com/Monadical-SAS/reflector/commit/fe47c46489c5aa0cc538109f7559cc9accb35c01))
|
||||||
|
* Skip mixdown for multitrack ([#760](https://github.com/Monadical-SAS/reflector/issues/760)) ([b51b7aa](https://github.com/Monadical-SAS/reflector/commit/b51b7aa9176c1a53ba57ad99f5e976c804a1e80c))
|
||||||
|
|
||||||
|
## [0.22.1](https://github.com/Monadical-SAS/reflector/compare/v0.22.0...v0.22.1) (2025-11-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* participants update from daily ([#749](https://github.com/Monadical-SAS/reflector/issues/749)) ([7f0b728](https://github.com/Monadical-SAS/reflector/commit/7f0b728991c1b9f9aae702c96297eae63b561ef5))
|
||||||
|
|
||||||
|
## [0.22.0](https://github.com/Monadical-SAS/reflector/compare/v0.21.0...v0.22.0) (2025-11-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Multitrack segmentation ([#747](https://github.com/Monadical-SAS/reflector/issues/747)) ([d63040e](https://github.com/Monadical-SAS/reflector/commit/d63040e2fdc07e7b272e85a39eb2411cd6a14798))
|
||||||
|
|
||||||
|
## [0.21.0](https://github.com/Monadical-SAS/reflector/compare/v0.20.0...v0.21.0) (2025-11-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add transcript format parameter to GET endpoint ([#709](https://github.com/Monadical-SAS/reflector/issues/709)) ([f6ca075](https://github.com/Monadical-SAS/reflector/commit/f6ca07505f34483b02270a2ef3bd809e9d2e1045))
|
||||||
|
|
||||||
|
## [0.20.0](https://github.com/Monadical-SAS/reflector/compare/v0.19.0...v0.20.0) (2025-11-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* link transcript participants ([#737](https://github.com/Monadical-SAS/reflector/issues/737)) ([9bec398](https://github.com/Monadical-SAS/reflector/commit/9bec39808fc6322612d8b87e922a6f7901fc01c1))
|
||||||
|
* transcript restart script ([#742](https://github.com/Monadical-SAS/reflector/issues/742)) ([86d5e26](https://github.com/Monadical-SAS/reflector/commit/86d5e26224bb55a0f1cc785aeda52065bb92ee6f))
|
||||||
|
|
||||||
|
## [0.19.0](https://github.com/Monadical-SAS/reflector/compare/v0.18.0...v0.19.0) (2025-11-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* dailyco api module ([#725](https://github.com/Monadical-SAS/reflector/issues/725)) ([4287f8b](https://github.com/Monadical-SAS/reflector/commit/4287f8b8aeee60e51db7539f4dcbda5f6e696bd8))
|
||||||
|
* dailyco poll ([#730](https://github.com/Monadical-SAS/reflector/issues/730)) ([8e438ca](https://github.com/Monadical-SAS/reflector/commit/8e438ca285152bd48fdc42767e706fb448d3525c))
|
||||||
|
* multitrack cli ([#735](https://github.com/Monadical-SAS/reflector/issues/735)) ([11731c9](https://github.com/Monadical-SAS/reflector/commit/11731c9d38439b04e93b1c3afbd7090bad11a11f))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* default platform fix ([#736](https://github.com/Monadical-SAS/reflector/issues/736)) ([c442a62](https://github.com/Monadical-SAS/reflector/commit/c442a627873ca667656eeaefb63e54ab10b8d19e))
|
||||||
|
* parakeet vad not getting the end timestamp ([#728](https://github.com/Monadical-SAS/reflector/issues/728)) ([18ed713](https://github.com/Monadical-SAS/reflector/commit/18ed7133693653ef4ddac6c659a8c14b320d1657))
|
||||||
|
* start raw tracks recording ([#729](https://github.com/Monadical-SAS/reflector/issues/729)) ([3e47c2c](https://github.com/Monadical-SAS/reflector/commit/3e47c2c0573504858e0d2e1798b6ed31f16b4a5d))
|
||||||
|
|
||||||
|
## [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)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* cleaned repo, and get git-leaks clean ([359280d](https://github.com/Monadical-SAS/reflector/commit/359280dd340433ba4402ed69034094884c825e67))
|
||||||
|
* restore previous behavior on live pipeline + audio downscaler ([#561](https://github.com/Monadical-SAS/reflector/issues/561)) ([9265d20](https://github.com/Monadical-SAS/reflector/commit/9265d201b590d23c628c5f19251b70f473859043))
|
||||||
|
|
||||||
|
## [0.7.2](https://github.com/Monadical-SAS/reflector/compare/v0.7.1...v0.7.2) (2025-08-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* docker image not loading libgomp.so.1 for torch ([#560](https://github.com/Monadical-SAS/reflector/issues/560)) ([773fccd](https://github.com/Monadical-SAS/reflector/commit/773fccd93e887c3493abc2e4a4864dddce610177))
|
||||||
|
* include shared rooms to search ([#558](https://github.com/Monadical-SAS/reflector/issues/558)) ([499eced](https://github.com/Monadical-SAS/reflector/commit/499eced3360b84fb3a90e1c8a3b554290d21adc2))
|
||||||
|
|
||||||
## [0.7.1](https://github.com/Monadical-SAS/reflector/compare/v0.7.0...v0.7.1) (2025-08-21)
|
## [0.7.1](https://github.com/Monadical-SAS/reflector/compare/v0.7.0...v0.7.1) (2025-08-21)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ pnpm install
|
|||||||
|
|
||||||
# Copy configuration templates
|
# Copy configuration templates
|
||||||
cp .env_template .env
|
cp .env_template .env
|
||||||
cp config-template.ts config.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Development:**
|
**Development:**
|
||||||
@@ -152,7 +151,7 @@ All endpoints prefixed `/v1/`:
|
|||||||
|
|
||||||
**Frontend** (`www/.env`):
|
**Frontend** (`www/.env`):
|
||||||
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration
|
- `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
|
- `REFLECTOR_DOMAIN_CONFIG` - Feature flags and domain settings
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|||||||
94
README.md
94
README.md
@@ -1,43 +1,60 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
<img width="100" alt="image" src="https://github.com/user-attachments/assets/66fb367b-2c89-4516-9912-f47ac59c6a7f"/>
|
||||||
|
|
||||||
# Reflector
|
# Reflector
|
||||||
|
|
||||||
Reflector Audio Management and Analysis is a cutting-edge web application under development by Monadical. It utilizes AI to record meetings, providing a permanent record with transcripts, translations, and automated summaries.
|
Reflector is an AI-powered audio transcription and meeting analysis platform that provides real-time transcription, speaker diarization, translation and summarization for audio content and live meetings. It works 100% with local models (whisper/parakeet, pyannote, seamless-m4t, and your local llm like phi-4).
|
||||||
|
|
||||||
[](https://github.com/monadical-sas/reflector/actions/workflows/pytests.yml)
|
[](https://github.com/monadical-sas/reflector/actions/workflows/test_server.yml)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
## Screenshots
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://github.com/user-attachments/assets/3a976930-56c1-47ef-8c76-55d3864309e3">
|
<a href="https://github.com/user-attachments/assets/21f5597c-2930-4899-a154-f7bd61a59e97">
|
||||||
<img width="700" alt="image" src="https://github.com/user-attachments/assets/3a976930-56c1-47ef-8c76-55d3864309e3" />
|
<img width="700" alt="image" src="https://github.com/user-attachments/assets/21f5597c-2930-4899-a154-f7bd61a59e97" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://github.com/user-attachments/assets/bfe3bde3-08af-4426-a9a1-11ad5cd63b33">
|
<a href="https://github.com/user-attachments/assets/f6b9399a-5e51-4bae-b807-59128d0a940c">
|
||||||
<img width="700" alt="image" src="https://github.com/user-attachments/assets/bfe3bde3-08af-4426-a9a1-11ad5cd63b33" />
|
<img width="700" alt="image" src="https://github.com/user-attachments/assets/f6b9399a-5e51-4bae-b807-59128d0a940c" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="https://github.com/user-attachments/assets/7b60c9d0-efe4-474f-a27b-ea13bd0fabdc">
|
<a href="https://github.com/user-attachments/assets/a42ce460-c1fd-4489-a995-270516193897">
|
||||||
<img width="700" alt="image" src="https://github.com/user-attachments/assets/7b60c9d0-efe4-474f-a27b-ea13bd0fabdc" />
|
<img width="700" alt="image" src="https://github.com/user-attachments/assets/a42ce460-c1fd-4489-a995-270516193897" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://github.com/user-attachments/assets/21929f6d-c309-42fe-9c11-f1299e50fbd4">
|
||||||
|
<img width="700" alt="image" src="https://github.com/user-attachments/assets/21929f6d-c309-42fe-9c11-f1299e50fbd4" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
## What is Reflector?
|
||||||
|
|
||||||
|
Reflector is a web application that utilizes local models to process audio content, providing:
|
||||||
|
|
||||||
|
- **Real-time Transcription**: Convert speech to text using [Whisper](https://github.com/openai/whisper) (multi-language) or [Parakeet](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v2) (English) models
|
||||||
|
- **Speaker Diarization**: Identify and label different speakers using [Pyannote](https://github.com/pyannote/pyannote-audio) 3.1
|
||||||
|
- **Live Translation**: Translate audio content in real-time to many languages with [Facebook Seamless-M4T](https://github.com/facebookresearch/seamless_communication)
|
||||||
|
- **Topic Detection & Summarization**: Extract key topics and generate concise summaries using LLMs
|
||||||
|
- **Meeting Recording**: Create permanent records of meetings with searchable transcripts
|
||||||
|
|
||||||
|
Currently we provide [modal.com](https://modal.com/) gpu template to deploy.
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
The project architecture consists of three primary components:
|
The project architecture consists of three primary components:
|
||||||
|
|
||||||
- **Front-End**: NextJS React project hosted on Vercel, located in `www/`.
|
|
||||||
- **Back-End**: Python server that offers an API and data persistence, found in `server/`.
|
- **Back-End**: Python server that offers an API and data persistence, found in `server/`.
|
||||||
- **GPU implementation**: Providing services such as speech-to-text transcription, topic generation, automated summaries, and translations. Most reliable option is Modal deployment
|
- **Front-End**: NextJS React project hosted on Vercel, located in `www/`.
|
||||||
|
- **GPU implementation**: Providing services such as speech-to-text transcription, topic generation, automated summaries, and translations.
|
||||||
|
|
||||||
It also uses authentik for authentication if activated, and Vercel for deployment and configuration of the front-end.
|
It also uses authentik for authentication if activated.
|
||||||
|
|
||||||
## Contribution Guidelines
|
## Contribution Guidelines
|
||||||
|
|
||||||
@@ -72,6 +89,8 @@ Note: We currently do not have instructions for Windows users.
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
*Note: we're working toward better installation, theses instructions are not accurate for now*
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Start with `cd www`.
|
Start with `cd www`.
|
||||||
@@ -80,11 +99,10 @@ Start with `cd www`.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
cp .env_template .env
|
cp .env.example .env
|
||||||
cp config-template.ts config.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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**
|
**Run in development mode**
|
||||||
|
|
||||||
@@ -149,3 +167,47 @@ You can manually process an audio file by calling the process tool:
|
|||||||
```bash
|
```bash
|
||||||
uv run python -m reflector.tools.process path/to/audio.wav
|
uv run python -m reflector.tools.process path/to/audio.wav
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Reprocessing any transcription
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run -m reflector.tools.process_transcript 81ec38d1-9dd7-43d2-b3f8-51f4d34a07cd --sync
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|||||||
64
compose.yml
64
compose.yml
@@ -1,64 +0,0 @@
|
|||||||
services:
|
|
||||||
server:
|
|
||||||
build:
|
|
||||||
context: server
|
|
||||||
ports:
|
|
||||||
- 1250:1250
|
|
||||||
volumes:
|
|
||||||
- ./server/:/app/
|
|
||||||
env_file:
|
|
||||||
- ./server/.env
|
|
||||||
environment:
|
|
||||||
ENTRYPOINT: server
|
|
||||||
|
|
||||||
worker:
|
|
||||||
build:
|
|
||||||
context: server
|
|
||||||
volumes:
|
|
||||||
- ./server/:/app/
|
|
||||||
env_file:
|
|
||||||
- ./server/.env
|
|
||||||
environment:
|
|
||||||
ENTRYPOINT: worker
|
|
||||||
|
|
||||||
beat:
|
|
||||||
build:
|
|
||||||
context: server
|
|
||||||
volumes:
|
|
||||||
- ./server/:/app/
|
|
||||||
env_file:
|
|
||||||
- ./server/.env
|
|
||||||
environment:
|
|
||||||
ENTRYPOINT: beat
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7.2
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
web:
|
|
||||||
image: node:18
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
command: sh -c "corepack enable && pnpm install && pnpm dev"
|
|
||||||
restart: unless-stopped
|
|
||||||
working_dir: /app
|
|
||||||
volumes:
|
|
||||||
- ./www:/app/
|
|
||||||
- /app/node_modules
|
|
||||||
env_file:
|
|
||||||
- ./www/.env.local
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:17
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: reflector
|
|
||||||
POSTGRES_PASSWORD: reflector
|
|
||||||
POSTGRES_DB: reflector
|
|
||||||
volumes:
|
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
|
||||||
|
|
||||||
networks:
|
|
||||||
default:
|
|
||||||
attachable: true
|
|
||||||
37
docker-compose.prod.yml
Normal file
37
docker-compose.prod.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Production Docker Compose configuration for Frontend
|
||||||
|
# Usage: docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: monadicalsas/reflector-frontend:latest
|
||||||
|
pull_policy: always
|
||||||
|
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:
|
||||||
120
docker-compose.yml
Normal file
120
docker-compose.yml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
services:
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: server
|
||||||
|
ports:
|
||||||
|
- 1250:1250
|
||||||
|
volumes:
|
||||||
|
- ./server/:/app/
|
||||||
|
- /app/.venv
|
||||||
|
env_file:
|
||||||
|
- ./server/.env
|
||||||
|
environment:
|
||||||
|
ENTRYPOINT: server
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: server
|
||||||
|
volumes:
|
||||||
|
- ./server/:/app/
|
||||||
|
- /app/.venv
|
||||||
|
env_file:
|
||||||
|
- ./server/.env
|
||||||
|
environment:
|
||||||
|
ENTRYPOINT: worker
|
||||||
|
|
||||||
|
beat:
|
||||||
|
build:
|
||||||
|
context: server
|
||||||
|
volumes:
|
||||||
|
- ./server/:/app/
|
||||||
|
- /app/.venv
|
||||||
|
env_file:
|
||||||
|
- ./server/.env
|
||||||
|
environment:
|
||||||
|
ENTRYPOINT: beat
|
||||||
|
|
||||||
|
hatchet-worker:
|
||||||
|
build:
|
||||||
|
context: server
|
||||||
|
volumes:
|
||||||
|
- ./server/:/app/
|
||||||
|
- /app/.venv
|
||||||
|
env_file:
|
||||||
|
- ./server/.env
|
||||||
|
environment:
|
||||||
|
ENTRYPOINT: hatchet-worker
|
||||||
|
depends_on:
|
||||||
|
hatchet:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7.2
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
web:
|
||||||
|
image: node:22-alpine
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
command: sh -c "corepack enable && pnpm install && pnpm dev"
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./www:/app/
|
||||||
|
- /app/node_modules
|
||||||
|
env_file:
|
||||||
|
- ./www/.env.local
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
command: postgres -c 'max_connections=200'
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: reflector
|
||||||
|
POSTGRES_PASSWORD: reflector
|
||||||
|
POSTGRES_DB: reflector
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
- ./server/docker/init-hatchet-db.sql:/docker-entrypoint-initdb.d/init-hatchet-db.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d reflector -U reflector"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
hatchet:
|
||||||
|
image: ghcr.io/hatchet-dev/hatchet/hatchet-lite:latest
|
||||||
|
ports:
|
||||||
|
- "8889:8888"
|
||||||
|
- "7078:7077"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "postgresql://reflector:reflector@postgres:5432/hatchet?sslmode=disable"
|
||||||
|
SERVER_AUTH_COOKIE_DOMAIN: localhost
|
||||||
|
SERVER_AUTH_COOKIE_INSECURE: "t"
|
||||||
|
SERVER_GRPC_BIND_ADDRESS: "0.0.0.0"
|
||||||
|
SERVER_GRPC_INSECURE: "t"
|
||||||
|
SERVER_GRPC_BROADCAST_ADDRESS: hatchet:7077
|
||||||
|
SERVER_GRPC_PORT: "7077"
|
||||||
|
SERVER_URL: http://localhost:8889
|
||||||
|
SERVER_AUTH_SET_EMAIL_VERIFIED: "t"
|
||||||
|
# SERVER_DEFAULT_ENGINE_VERSION: "V1" # default
|
||||||
|
SERVER_INTERNAL_CLIENT_INTERNAL_GRPC_BROADCAST_ADDRESS: hatchet:7077
|
||||||
|
volumes:
|
||||||
|
- ./data/hatchet-config:/config
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8888/api/live"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
attachable: true
|
||||||
241
docs/transcript.md
Normal file
241
docs/transcript.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# Transcript Formats
|
||||||
|
|
||||||
|
The Reflector API provides multiple output formats for transcript data through the `transcript_format` query parameter on the GET `/v1/transcripts/{id}` endpoint.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When retrieving a transcript, you can specify the desired format using the `transcript_format` query parameter. The API supports four formats optimized for different use cases:
|
||||||
|
|
||||||
|
- **text** - Plain text with speaker names (default)
|
||||||
|
- **text-timestamped** - Timestamped text with speaker names
|
||||||
|
- **webvtt-named** - WebVTT subtitle format with participant names
|
||||||
|
- **json** - Structured JSON segments with full metadata
|
||||||
|
|
||||||
|
All formats include participant information when available, resolving speaker IDs to actual names.
|
||||||
|
|
||||||
|
## Query Parameter Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/transcripts/{id}?transcript_format={format}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
- `transcript_format` (optional): The desired output format
|
||||||
|
- Type: `"text" | "text-timestamped" | "webvtt-named" | "json"`
|
||||||
|
- Default: `"text"`
|
||||||
|
|
||||||
|
## Format Descriptions
|
||||||
|
|
||||||
|
### Text Format (`text`)
|
||||||
|
|
||||||
|
**Use case:** Simple, human-readable transcript for display or export.
|
||||||
|
|
||||||
|
**Format:** Speaker names followed by their dialogue, one line per segment.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
John Smith: Hello everyone
|
||||||
|
Jane Doe: Hi there
|
||||||
|
John Smith: How are you today?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
GET /v1/transcripts/{id}?transcript_format=text
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "transcript_123",
|
||||||
|
"name": "Meeting Recording",
|
||||||
|
"transcript_format": "text",
|
||||||
|
"transcript": "John Smith: Hello everyone\nJane Doe: Hi there\nJohn Smith: How are you today?",
|
||||||
|
"participants": [
|
||||||
|
{"id": "p1", "speaker": 0, "name": "John Smith"},
|
||||||
|
{"id": "p2", "speaker": 1, "name": "Jane Doe"}
|
||||||
|
],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Timestamped Format (`text-timestamped`)
|
||||||
|
|
||||||
|
**Use case:** Transcript with timing information for navigation or reference.
|
||||||
|
|
||||||
|
**Format:** `[MM:SS]` timestamp prefix before each speaker and dialogue.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
[00:00] John Smith: Hello everyone
|
||||||
|
[00:05] Jane Doe: Hi there
|
||||||
|
[00:12] John Smith: How are you today?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
GET /v1/transcripts/{id}?transcript_format=text-timestamped
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "transcript_123",
|
||||||
|
"name": "Meeting Recording",
|
||||||
|
"transcript_format": "text-timestamped",
|
||||||
|
"transcript": "[00:00] John Smith: Hello everyone\n[00:05] Jane Doe: Hi there\n[00:12] John Smith: How are you today?",
|
||||||
|
"participants": [
|
||||||
|
{"id": "p1", "speaker": 0, "name": "John Smith"},
|
||||||
|
{"id": "p2", "speaker": 1, "name": "Jane Doe"}
|
||||||
|
],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebVTT Named Format (`webvtt-named`)
|
||||||
|
|
||||||
|
**Use case:** Subtitle files for video players, accessibility tools, or video editing.
|
||||||
|
|
||||||
|
**Format:** Standard WebVTT subtitle format with voice tags using participant names.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.000 --> 00:00:05.000
|
||||||
|
<v John Smith>Hello everyone
|
||||||
|
|
||||||
|
00:00:05.000 --> 00:00:12.000
|
||||||
|
<v Jane Doe>Hi there
|
||||||
|
|
||||||
|
00:00:12.000 --> 00:00:18.000
|
||||||
|
<v John Smith>How are you today?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
GET /v1/transcripts/{id}?transcript_format=webvtt-named
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "transcript_123",
|
||||||
|
"name": "Meeting Recording",
|
||||||
|
"transcript_format": "webvtt-named",
|
||||||
|
"transcript": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\n<v John Smith>Hello everyone\n\n...",
|
||||||
|
"participants": [
|
||||||
|
{"id": "p1", "speaker": 0, "name": "John Smith"},
|
||||||
|
{"id": "p2", "speaker": 1, "name": "Jane Doe"}
|
||||||
|
],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON Format (`json`)
|
||||||
|
|
||||||
|
**Use case:** Programmatic access with full timing and speaker metadata.
|
||||||
|
|
||||||
|
**Format:** Array of segment objects with speaker information, text content, and precise timing.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"speaker": 0,
|
||||||
|
"speaker_name": "John Smith",
|
||||||
|
"text": "Hello everyone",
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 5.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"speaker": 1,
|
||||||
|
"speaker_name": "Jane Doe",
|
||||||
|
"text": "Hi there",
|
||||||
|
"start": 5.0,
|
||||||
|
"end": 12.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"speaker": 0,
|
||||||
|
"speaker_name": "John Smith",
|
||||||
|
"text": "How are you today?",
|
||||||
|
"start": 12.0,
|
||||||
|
"end": 18.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
GET /v1/transcripts/{id}?transcript_format=json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "transcript_123",
|
||||||
|
"name": "Meeting Recording",
|
||||||
|
"transcript_format": "json",
|
||||||
|
"transcript": [
|
||||||
|
{
|
||||||
|
"speaker": 0,
|
||||||
|
"speaker_name": "John Smith",
|
||||||
|
"text": "Hello everyone",
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 5.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"speaker": 1,
|
||||||
|
"speaker_name": "Jane Doe",
|
||||||
|
"text": "Hi there",
|
||||||
|
"start": 5.0,
|
||||||
|
"end": 12.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"participants": [
|
||||||
|
{"id": "p1", "speaker": 0, "name": "John Smith"},
|
||||||
|
{"id": "p2", "speaker": 1, "name": "Jane Doe"}
|
||||||
|
],
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response Structure
|
||||||
|
|
||||||
|
All formats return the same base transcript metadata with an additional `transcript_format` field and format-specific `transcript` field:
|
||||||
|
|
||||||
|
### Common Fields
|
||||||
|
|
||||||
|
- `id`: Transcript identifier
|
||||||
|
- `user_id`: Owner user ID (if authenticated)
|
||||||
|
- `name`: Transcript name
|
||||||
|
- `status`: Processing status
|
||||||
|
- `locked`: Whether transcript is locked for editing
|
||||||
|
- `duration`: Total duration in seconds
|
||||||
|
- `title`: Auto-generated or custom title
|
||||||
|
- `short_summary`: Brief summary
|
||||||
|
- `long_summary`: Detailed summary
|
||||||
|
- `created_at`: Creation timestamp
|
||||||
|
- `share_mode`: Access control setting
|
||||||
|
- `source_language`: Original audio language
|
||||||
|
- `target_language`: Translation target language
|
||||||
|
- `reviewed`: Whether transcript has been reviewed
|
||||||
|
- `meeting_id`: Associated meeting ID (if applicable)
|
||||||
|
- `source_kind`: Source type (live, file, room)
|
||||||
|
- `room_id`: Associated room ID (if applicable)
|
||||||
|
- `audio_deleted`: Whether audio has been deleted
|
||||||
|
- `participants`: Array of participant objects with speaker mappings
|
||||||
|
|
||||||
|
### Format-Specific Fields
|
||||||
|
|
||||||
|
- `transcript_format`: The format identifier (discriminator field)
|
||||||
|
- `transcript`: The formatted transcript content (string for text/webvtt formats, array for json format)
|
||||||
|
|
||||||
|
## Speaker Name Resolution
|
||||||
|
|
||||||
|
All formats resolve speaker IDs to participant names when available:
|
||||||
|
|
||||||
|
- If a participant exists for the speaker ID, their name is used
|
||||||
|
- If no participant exists, a default name like "Speaker 0" is generated
|
||||||
|
- Speaker IDs are integers (0, 1, 2, etc.) assigned during diarization
|
||||||
33
gpu/modal_deployments/.gitignore
vendored
Normal file
33
gpu/modal_deployments/.gitignore
vendored
Normal file
@@ -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/
|
||||||
608
gpu/modal_deployments/reflector_transcriber.py
Normal file
608
gpu/modal_deployments/reflector_transcriber.py
Normal file
@@ -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()
|
||||||
@@ -3,7 +3,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Mapping, NewType
|
from typing import Generator, Mapping, NamedTuple, NewType, TypedDict
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import modal
|
import modal
|
||||||
@@ -14,10 +14,7 @@ SAMPLERATE = 16000
|
|||||||
UPLOADS_PATH = "/uploads"
|
UPLOADS_PATH = "/uploads"
|
||||||
CACHE_PATH = "/cache"
|
CACHE_PATH = "/cache"
|
||||||
VAD_CONFIG = {
|
VAD_CONFIG = {
|
||||||
"max_segment_duration": 30.0,
|
"batch_max_duration": 30.0,
|
||||||
"batch_max_files": 10,
|
|
||||||
"batch_max_duration": 5.0,
|
|
||||||
"min_segment_duration": 0.02,
|
|
||||||
"silence_padding": 0.5,
|
"silence_padding": 0.5,
|
||||||
"window_size": 512,
|
"window_size": 512,
|
||||||
}
|
}
|
||||||
@@ -25,6 +22,37 @@ VAD_CONFIG = {
|
|||||||
ParakeetUniqFilename = NewType("ParakeetUniqFilename", str)
|
ParakeetUniqFilename = NewType("ParakeetUniqFilename", str)
|
||||||
AudioFileExtension = NewType("AudioFileExtension", 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")
|
app = modal.App("reflector-transcriber-parakeet")
|
||||||
|
|
||||||
# Volume for caching model weights
|
# Volume for caching model weights
|
||||||
@@ -49,13 +77,13 @@ image = (
|
|||||||
.pip_install(
|
.pip_install(
|
||||||
"hf_transfer==0.1.9",
|
"hf_transfer==0.1.9",
|
||||||
"huggingface_hub[hf-xet]==0.31.2",
|
"huggingface_hub[hf-xet]==0.31.2",
|
||||||
"nemo_toolkit[asr]==2.3.0",
|
"nemo_toolkit[asr]==2.5.0",
|
||||||
"cuda-python==12.8.0",
|
"cuda-python==12.8.0",
|
||||||
"fastapi==0.115.12",
|
"fastapi==0.115.12",
|
||||||
"numpy<2",
|
"numpy<2",
|
||||||
"librosa==0.10.1",
|
"librosa==0.11.0",
|
||||||
"requests",
|
"requests",
|
||||||
"silero-vad==5.1.0",
|
"silero-vad==6.2.0",
|
||||||
"torch",
|
"torch",
|
||||||
)
|
)
|
||||||
.entrypoint([]) # silence chatty logs by container on start
|
.entrypoint([]) # silence chatty logs by container on start
|
||||||
@@ -170,12 +198,14 @@ class TranscriberParakeetLive:
|
|||||||
(output,) = self.model.transcribe([padded_audio], timestamps=True)
|
(output,) = self.model.transcribe([padded_audio], timestamps=True)
|
||||||
|
|
||||||
text = output.text.strip()
|
text = output.text.strip()
|
||||||
words = [
|
words: list[WordTiming] = [
|
||||||
{
|
WordTiming(
|
||||||
"word": word_info["word"],
|
# XXX the space added here is to match the output of whisper
|
||||||
"start": round(word_info["start"], 2),
|
# whisper add space to each words, while parakeet don't
|
||||||
"end": round(word_info["end"], 2),
|
word=word_info["word"] + " ",
|
||||||
}
|
start=round(word_info["start"], 2),
|
||||||
|
end=round(word_info["end"], 2),
|
||||||
|
)
|
||||||
for word_info in output.timestamp["word"]
|
for word_info in output.timestamp["word"]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -211,12 +241,12 @@ class TranscriberParakeetLive:
|
|||||||
for i, (filename, output) in enumerate(zip(filenames, outputs)):
|
for i, (filename, output) in enumerate(zip(filenames, outputs)):
|
||||||
text = output.text.strip()
|
text = output.text.strip()
|
||||||
|
|
||||||
words = [
|
words: list[WordTiming] = [
|
||||||
{
|
WordTiming(
|
||||||
"word": word_info["word"],
|
word=word_info["word"] + " ",
|
||||||
"start": round(word_info["start"], 2),
|
start=round(word_info["start"], 2),
|
||||||
"end": round(word_info["end"], 2),
|
end=round(word_info["end"], 2),
|
||||||
}
|
)
|
||||||
for word_info in output.timestamp["word"]
|
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)
|
audio_array, sample_rate = librosa.load(file_path, sr=SAMPLERATE, mono=True)
|
||||||
return audio_array
|
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"""
|
"""Generate speech segments using VAD with start/end sample indices"""
|
||||||
vad_iterator = VADIterator(self.vad_model, sampling_rate=SAMPLERATE)
|
vad_iterator = VADIterator(self.vad_model, sampling_rate=SAMPLERATE)
|
||||||
|
audio_duration = len(audio_array) / float(SAMPLERATE)
|
||||||
window_size = VAD_CONFIG["window_size"]
|
window_size = VAD_CONFIG["window_size"]
|
||||||
start = None
|
start = None
|
||||||
|
|
||||||
@@ -297,107 +330,125 @@ class TranscriberParakeetFile:
|
|||||||
start_time = start / float(SAMPLERATE)
|
start_time = start / float(SAMPLERATE)
|
||||||
end_time = end / float(SAMPLERATE)
|
end_time = end / float(SAMPLERATE)
|
||||||
|
|
||||||
# Extract the actual audio segment
|
yield TimeSegment(start_time, end_time)
|
||||||
audio_segment = audio_array[start:end]
|
|
||||||
|
|
||||||
yield (start_time, end_time, audio_segment)
|
|
||||||
start = None
|
start = None
|
||||||
|
|
||||||
|
if start is not None:
|
||||||
|
start_time = start / float(SAMPLERATE)
|
||||||
|
yield TimeSegment(start_time, audio_duration)
|
||||||
|
|
||||||
vad_iterator.reset_states()
|
vad_iterator.reset_states()
|
||||||
|
|
||||||
def vad_segment_filter(segments):
|
def batch_speech_segments(
|
||||||
"""Filter VAD segments by duration and chunk large segments"""
|
segments: Generator[TimeSegment, None, None], max_duration: int
|
||||||
min_dur = VAD_CONFIG["min_segment_duration"]
|
) -> Generator[TimeSegment, None, None]:
|
||||||
max_dur = VAD_CONFIG["max_segment_duration"]
|
"""
|
||||||
|
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:
|
↓ (max_duration=10)
|
||||||
segment_duration = end_time - start_time
|
|
||||||
|
|
||||||
# Skip very small segments
|
Output batches:
|
||||||
if segment_duration < min_dur:
|
[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
|
continue
|
||||||
|
|
||||||
# If segment is within max duration, yield as-is
|
total_duration = end_time - batch_start_time
|
||||||
if segment_duration <= max_dur:
|
|
||||||
yield (start_time, end_time, audio_segment)
|
if total_duration <= max_duration:
|
||||||
|
batch_end_time = end_time
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Chunk large segments into smaller pieces
|
yield TimeSegment(batch_start_time, batch_end_time)
|
||||||
chunk_samples = int(max_dur * SAMPLERATE)
|
batch_start_time = start_time
|
||||||
current_start = start_time
|
batch_end_time = end_time
|
||||||
|
|
||||||
for chunk_offset in range(0, len(audio_segment), chunk_samples):
|
if batch_start_time is None or batch_end_time is None:
|
||||||
chunk_audio = audio_segment[
|
return
|
||||||
chunk_offset : chunk_offset + chunk_samples
|
|
||||||
]
|
|
||||||
if len(chunk_audio) == 0:
|
|
||||||
break
|
|
||||||
|
|
||||||
chunk_duration = len(chunk_audio) / float(SAMPLERATE)
|
yield TimeSegment(batch_start_time, batch_end_time)
|
||||||
chunk_end = current_start + chunk_duration
|
|
||||||
|
|
||||||
# Only yield chunks that meet minimum duration
|
def batch_segment_to_audio_segment(
|
||||||
if chunk_duration >= min_dur:
|
segments: Generator[TimeSegment, None, None],
|
||||||
yield (current_start, chunk_end, chunk_audio)
|
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):
|
padded_segment = pad_audio(audio_segment, SAMPLERATE)
|
||||||
batch = []
|
|
||||||
batch_duration = 0.0
|
|
||||||
|
|
||||||
for start_time, end_time, audio_segment in segments:
|
yield AudioSegment(start_time, end_time, padded_segment)
|
||||||
segment_duration = end_time - start_time
|
|
||||||
|
|
||||||
if segment_duration < VAD_CONFIG["silence_padding"]:
|
def transcribe_batch(model, audio_segments: list) -> list:
|
||||||
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):
|
|
||||||
with NoStdStreams():
|
with NoStdStreams():
|
||||||
outputs = model.transcribe(audio_segments, timestamps=True)
|
outputs = model.transcribe(audio_segments, timestamps=True)
|
||||||
return outputs
|
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(
|
def emit_results(
|
||||||
results,
|
results: list,
|
||||||
segments_info,
|
segments_info: list[AudioSegment],
|
||||||
batch_index,
|
) -> Generator[TranscriptResult, None, None]:
|
||||||
total_batches,
|
|
||||||
):
|
|
||||||
"""Yield transcribed text and word timings from model output, adjusting timestamps to absolute positions."""
|
"""Yield transcribed text and word timings from model output, adjusting timestamps to absolute positions."""
|
||||||
for i, (output, (start_time, end_time, _)) in enumerate(
|
for i, (output, segment) in enumerate(zip(results, segments_info)):
|
||||||
zip(results, segments_info)
|
start_time, end_time = segment.start, segment.end
|
||||||
):
|
|
||||||
text = output.text.strip()
|
text = output.text.strip()
|
||||||
words = [
|
words: list[WordTiming] = [
|
||||||
{
|
WordTiming(
|
||||||
"word": word_info["word"],
|
word=word_info["word"] + " ",
|
||||||
"start": round(
|
start=round(
|
||||||
word_info["start"] + start_time + timestamp_offset, 2
|
word_info["start"] + start_time + timestamp_offset, 2
|
||||||
),
|
),
|
||||||
"end": round(
|
end=round(word_info["end"] + start_time + timestamp_offset, 2),
|
||||||
word_info["end"] + start_time + timestamp_offset, 2
|
)
|
||||||
),
|
|
||||||
}
|
|
||||||
for word_info in output.timestamp["word"]
|
for word_info in output.timestamp["word"]
|
||||||
]
|
]
|
||||||
|
|
||||||
yield text, words
|
yield TranscriptResult(text, words)
|
||||||
|
|
||||||
upload_volume.reload()
|
upload_volume.reload()
|
||||||
|
|
||||||
@@ -407,41 +458,31 @@ class TranscriberParakeetFile:
|
|||||||
|
|
||||||
audio_array = load_and_convert_audio(file_path)
|
audio_array = load_and_convert_audio(file_path)
|
||||||
total_duration = len(audio_array) / float(SAMPLERATE)
|
total_duration = len(audio_array) / float(SAMPLERATE)
|
||||||
processed_duration = 0.0
|
|
||||||
|
|
||||||
all_text_parts = []
|
all_text_parts: list[str] = []
|
||||||
all_words = []
|
all_words: list[WordTiming] = []
|
||||||
|
|
||||||
raw_segments = vad_segment_generator(audio_array)
|
raw_segments = vad_segment_generator(audio_array)
|
||||||
filtered_segments = vad_segment_filter(raw_segments)
|
speech_segments = batch_speech_segments(
|
||||||
batches = batch_segments(
|
raw_segments,
|
||||||
filtered_segments,
|
|
||||||
VAD_CONFIG["batch_max_files"],
|
|
||||||
VAD_CONFIG["batch_max_duration"],
|
VAD_CONFIG["batch_max_duration"],
|
||||||
)
|
)
|
||||||
|
audio_segments = batch_segment_to_audio_segment(speech_segments, audio_array)
|
||||||
|
|
||||||
batch_index = 0
|
for batch in audio_segments:
|
||||||
total_batches = max(
|
audio_segment = batch.audio
|
||||||
1, int(total_duration / VAD_CONFIG["batch_max_duration"]) + 1
|
results = transcribe_batch(self.model, [audio_segment])
|
||||||
)
|
|
||||||
|
|
||||||
for batch in batches:
|
for result in emit_results(
|
||||||
batch_index += 1
|
|
||||||
audio_segments = [seg[2] for seg in batch]
|
|
||||||
results = transcribe_batch(self.model, audio_segments)
|
|
||||||
|
|
||||||
for text, words in emit_results(
|
|
||||||
results,
|
results,
|
||||||
batch,
|
[batch],
|
||||||
batch_index,
|
|
||||||
total_batches,
|
|
||||||
):
|
):
|
||||||
if not text:
|
if not result.text:
|
||||||
continue
|
continue
|
||||||
all_text_parts.append(text)
|
all_text_parts.append(result.text)
|
||||||
all_words.extend(words)
|
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)
|
combined_text = " ".join(all_text_parts)
|
||||||
return {"text": combined_text, "words": all_words}
|
return {"text": combined_text, "words": all_words}
|
||||||
2
gpu/self_hosted/.env.example
Normal file
2
gpu/self_hosted/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
REFLECTOR_GPU_APIKEY=
|
||||||
|
HF_TOKEN=
|
||||||
38
gpu/self_hosted/.gitignore
vendored
Normal file
38
gpu/self_hosted/.gitignore
vendored
Normal file
@@ -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
|
||||||
46
gpu/self_hosted/Dockerfile
Normal file
46
gpu/self_hosted/Dockerfile
Normal file
@@ -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"]
|
||||||
|
|
||||||
|
|
||||||
73
gpu/self_hosted/README.md
Normal file
73
gpu/self_hosted/README.md
Normal file
@@ -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 <key>
|
||||||
|
|
||||||
|
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: { <src>: original, <tgt>: 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
|
||||||
19
gpu/self_hosted/app/auth.py
Normal file
19
gpu/self_hosted/app/auth.py
Normal file
@@ -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"},
|
||||||
|
)
|
||||||
12
gpu/self_hosted/app/config.py
Normal file
12
gpu/self_hosted/app/config.py
Normal file
@@ -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")
|
||||||
30
gpu/self_hosted/app/factory.py
Normal file
30
gpu/self_hosted/app/factory.py
Normal file
@@ -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
|
||||||
30
gpu/self_hosted/app/routers/diarization.py
Normal file
30
gpu/self_hosted/app/routers/diarization.py
Normal file
@@ -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)
|
||||||
109
gpu/self_hosted/app/routers/transcription.py
Normal file
109
gpu/self_hosted/app/routers/transcription.py
Normal file
@@ -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
|
||||||
28
gpu/self_hosted/app/routers/translation.py
Normal file
28
gpu/self_hosted/app/routers/translation.py
Normal file
@@ -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)
|
||||||
42
gpu/self_hosted/app/services/diarizer.py
Normal file
42
gpu/self_hosted/app/services/diarizer.py
Normal file
@@ -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}
|
||||||
208
gpu/self_hosted/app/services/transcriber.py
Normal file
208
gpu/self_hosted/app/services/transcriber.py
Normal file
@@ -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}
|
||||||
44
gpu/self_hosted/app/services/translator.py
Normal file
44
gpu/self_hosted/app/services/translator.py
Normal file
@@ -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}}
|
||||||
107
gpu/self_hosted/app/utils.py
Normal file
107
gpu/self_hosted/app/utils.py
Normal file
@@ -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)
|
||||||
10
gpu/self_hosted/compose.yml
Normal file
10
gpu/self_hosted/compose.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
reflector_gpu:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./cache:/root/.cache
|
||||||
3
gpu/self_hosted/main.py
Normal file
3
gpu/self_hosted/main.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from app.factory import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
19
gpu/self_hosted/pyproject.toml
Normal file
19
gpu/self_hosted/pyproject.toml
Normal file
@@ -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",
|
||||||
|
]
|
||||||
17
gpu/self_hosted/runserver.sh
Normal file
17
gpu/self_hosted/runserver.sh
Normal file
@@ -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
|
||||||
|
|
||||||
|
|
||||||
3013
gpu/self_hosted/uv.lock
generated
Normal file
3013
gpu/self_hosted/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,4 +27,15 @@ COPY migrations /app/migrations
|
|||||||
COPY reflector /app/reflector
|
COPY reflector /app/reflector
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create symlink for libgomp if it doesn't exist (for ARM64 compatibility)
|
||||||
|
RUN if [ "$(uname -m)" = "aarch64" ] && [ ! -f /usr/lib/libgomp.so.1 ]; then \
|
||||||
|
LIBGOMP_PATH=$(find /app/.venv/lib -path "*/torch.libs/libgomp*.so.*" 2>/dev/null | head -n1); \
|
||||||
|
if [ -n "$LIBGOMP_PATH" ]; then \
|
||||||
|
ln -sf "$LIBGOMP_PATH" /usr/lib/libgomp.so.1; \
|
||||||
|
fi \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pre-check just to make sure the image will not fail
|
||||||
|
RUN uv run python -c "import silero_vad.model"
|
||||||
|
|
||||||
CMD ["./runserver.sh"]
|
CMD ["./runserver.sh"]
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
## API Key Management
|
||||||
|
|
||||||
|
### Finding Your User ID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get your OAuth sub (user ID) - requires authentication
|
||||||
|
curl -H "Authorization: Bearer <your_jwt>" 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 <your_jwt>" \
|
||||||
|
-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: <your_api_key>" http://localhost:1250/v1/transcripts
|
||||||
|
```
|
||||||
|
|
||||||
## AWS S3/SQS usage clarification
|
## AWS S3/SQS usage clarification
|
||||||
|
|
||||||
Whereby.com uploads recordings directly to our S3 bucket when meetings end.
|
Whereby.com uploads recordings directly to our S3 bucket when meetings end.
|
||||||
@@ -27,6 +53,36 @@ response = sqs.receive_message(QueueUrl=queue_url, ...)
|
|||||||
uv run /app/requeue_uploaded_file.py TRANSCRIPT_ID
|
uv run /app/requeue_uploaded_file.py TRANSCRIPT_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Hatchet Setup (Fresh DB)
|
||||||
|
|
||||||
|
After resetting the Hatchet database:
|
||||||
|
|
||||||
|
### Option A: Automatic (CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get default tenant ID and create token in one command
|
||||||
|
TENANT_ID=$(docker compose exec -T postgres psql -U reflector -d hatchet -t -c \
|
||||||
|
"SELECT id FROM \"Tenant\" WHERE slug = 'default';" | tr -d ' \n') && \
|
||||||
|
TOKEN=$(docker compose exec -T hatchet /hatchet-admin token create \
|
||||||
|
--config /config --tenant-id "$TENANT_ID" 2>/dev/null | tr -d '\n') && \
|
||||||
|
echo "HATCHET_CLIENT_TOKEN=$TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the output to `server/.env`.
|
||||||
|
|
||||||
|
### Option B: Manual (UI)
|
||||||
|
|
||||||
|
1. Create API token at http://localhost:8889 → Settings → API Tokens
|
||||||
|
2. Update `server/.env`: `HATCHET_CLIENT_TOKEN=<new-token>`
|
||||||
|
|
||||||
|
### Then restart workers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart server hatchet-worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflows register automatically when hatchet-worker starts.
|
||||||
|
|
||||||
## Pipeline Management
|
## Pipeline Management
|
||||||
|
|
||||||
### Continue stuck pipeline from final summaries (identify_participants) step:
|
### Continue stuck pipeline from final summaries (identify_participants) step:
|
||||||
|
|||||||
2
server/docker/init-hatchet-db.sql
Normal file
2
server/docker/init-hatchet-db.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Create hatchet database for Hatchet workflow engine
|
||||||
|
CREATE DATABASE hatchet;
|
||||||
95
server/docs/data_retention.md
Normal file
95
server/docs/data_retention.md
Normal file
@@ -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
|
||||||
194
server/docs/gpu/api-transcription.md
Normal file
194
server/docs/gpu/api-transcription.md
Normal file
@@ -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://<account>--reflector-transcriber-parakeet-web.modal.run`
|
||||||
|
- Whisper: `https://<account>--reflector-transcriber-web.modal.run`
|
||||||
|
|
||||||
|
- All endpoints are served under `/v1` and require a Bearer token:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <REFLECTOR_GPU_APIKEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
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://<account>--reflector-transcriber-parakeet-web.modal.run
|
||||||
|
TRANSCRIPT_MODAL_API_KEY=<REFLECTOR_GPU_APIKEY>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conformance tests
|
||||||
|
|
||||||
|
Use the pytest-based conformance tests to validate any new implementation (including self-hosted) against this spec:
|
||||||
|
|
||||||
|
```
|
||||||
|
TRANSCRIPT_URL=https://<your-deployment-base> \
|
||||||
|
TRANSCRIPT_MODAL_API_KEY=your-api-key \
|
||||||
|
uv run -m pytest -m model_api --no-cov server/tests/test_model_api_transcript.py
|
||||||
|
```
|
||||||
236
server/docs/video-platforms/README.md
Normal file
236
server/docs/video-platforms/README.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Reflector Architecture: Whereby + Daily.co Recording Storage
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Actors"
|
||||||
|
APP[Our App<br/>Reflector]
|
||||||
|
WHEREBY[Whereby Service<br/>External]
|
||||||
|
DAILY[Daily.co Service<br/>External]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "AWS S3 Buckets"
|
||||||
|
TRANSCRIPT_BUCKET[Transcript Bucket<br/>reflector-transcripts<br/>Output: Processed MP3s]
|
||||||
|
WHEREBY_BUCKET[Whereby Bucket<br/>reflector-whereby-recordings<br/>Input: Raw MP4s]
|
||||||
|
DAILY_BUCKET[Daily.co Bucket<br/>reflector-dailyco-recordings<br/>Input: Raw WebM tracks]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "AWS Infrastructure"
|
||||||
|
SQS[SQS Queue<br/>Whereby notifications]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Database"
|
||||||
|
DB[(PostgreSQL<br/>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<br/>room.client.joined/left| APP
|
||||||
|
|
||||||
|
DAILY -->|Write recordings| DAILY_BUCKET
|
||||||
|
DAILY -->|Recording webhook<br/>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_*<br/>Access Key ID + Secret]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Whereby Upload Credentials"
|
||||||
|
WHEREBY_CREDS[AWS_WHEREBY_ACCESS_KEY_*<br/>Access Key ID + Secret]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Daily.co Upload Role"
|
||||||
|
DAILY_ROLE[DAILY_STORAGE_AWS_ROLE_ARN<br/>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
|
||||||
|
|
||||||
|
**Note:** Primary discovery via polling (`poll_daily_recordings`), webhooks as backup.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
233
server/docs/webhook.md
Normal file
233
server/docs/webhook.md
Normal file
@@ -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\n<v Speaker 1>Welcome 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\n<v Speaker 1>Welcome 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\n<v Speaker 2>Let'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)"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -27,7 +27,7 @@ AUTH_JWT_AUDIENCE=
|
|||||||
#TRANSCRIPT_MODAL_API_KEY=xxxxx
|
#TRANSCRIPT_MODAL_API_KEY=xxxxx
|
||||||
|
|
||||||
TRANSCRIPT_BACKEND=modal
|
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=
|
TRANSCRIPT_MODAL_API_KEY=
|
||||||
|
|
||||||
## =======================================================
|
## =======================================================
|
||||||
@@ -71,3 +71,30 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run
|
|||||||
|
|
||||||
## Sentry DSN configuration
|
## Sentry DSN configuration
|
||||||
#SENTRY_DSN=
|
#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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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 ###
|
||||||
26
server/migrations/versions/05f8688d6895_add_action_items.py
Normal file
26
server/migrations/versions/05f8688d6895_add_action_items.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""add_action_items
|
||||||
|
|
||||||
|
Revision ID: 05f8688d6895
|
||||||
|
Revises: bbafedfa510c
|
||||||
|
Create Date: 2025-12-12 11:57:50.209658
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "05f8688d6895"
|
||||||
|
down_revision: Union[str, None] = "bbafedfa510c"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("transcript", sa.Column("action_items", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("transcript", "action_items")
|
||||||
@@ -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 ###
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add workflow_run_id to transcript
|
||||||
|
|
||||||
|
Revision ID: 0f943fede0e0
|
||||||
|
Revises: 20251217000000
|
||||||
|
Create Date: 2025-12-16 01:54:13.855106
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0f943fede0e0"
|
||||||
|
down_revision: Union[str, None] = "20251217000000"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("transcript", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("workflow_run_id", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("transcript", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("workflow_run_id")
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add skip_consent to room
|
||||||
|
|
||||||
|
Revision ID: 20251217000000
|
||||||
|
Revises: 05f8688d6895
|
||||||
|
Create Date: 2025-12-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 = "20251217000000"
|
||||||
|
down_revision: Union[str, None] = "05f8688d6895"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"skip_consent",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("skip_consent")
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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 ###
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Make room platform non-nullable with dynamic default
|
||||||
|
|
||||||
|
Revision ID: 5d6b9df9b045
|
||||||
|
Revises: 2b92a1b03caa
|
||||||
|
Create Date: 2025-11-21 13:22:25.756584
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "5d6b9df9b045"
|
||||||
|
down_revision: Union[str, None] = "2b92a1b03caa"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("UPDATE room SET platform = 'whereby' WHERE platform IS NULL")
|
||||||
|
|
||||||
|
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||||
|
batch_op.alter_column("platform", existing_type=sa.String(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||||
|
batch_op.alter_column("platform", existing_type=sa.String(), nullable=True)
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -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 ###
|
||||||
@@ -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 ###
|
||||||
38
server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py
Normal file
38
server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py
Normal file
@@ -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")
|
||||||
38
server/migrations/versions/bbafedfa510c_add_user_table.py
Normal file
38
server/migrations/versions/bbafedfa510c_add_user_table.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""add user table
|
||||||
|
|
||||||
|
Revision ID: bbafedfa510c
|
||||||
|
Revises: 5d6b9df9b045
|
||||||
|
Create Date: 2025-11-19 21:06:30.543262
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "bbafedfa510c"
|
||||||
|
down_revision: Union[str, None] = "5d6b9df9b045"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user",
|
||||||
|
sa.Column("id", sa.String(), nullable=False),
|
||||||
|
sa.Column("email", sa.String(), nullable=False),
|
||||||
|
sa.Column("authentik_uid", sa.String(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table("user", schema=None) as batch_op:
|
||||||
|
batch_op.create_index("idx_user_authentik_uid", ["authentik_uid"], unique=True)
|
||||||
|
batch_op.create_index("idx_user_email", ["email"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("user")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add use_hatchet to room
|
||||||
|
|
||||||
|
Revision ID: bd3a729bb379
|
||||||
|
Revises: 0f943fede0e0
|
||||||
|
Create Date: 2025-12-16 16:34:03.594231
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "bd3a729bb379"
|
||||||
|
down_revision: Union[str, None] = "0f943fede0e0"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"use_hatchet",
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("use_hatchet")
|
||||||
@@ -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")
|
||||||
129
server/migrations/versions/d8e204bbf615_add_calendar.py
Normal file
129
server/migrations/versions/d8e204bbf615_add_calendar.py
Normal file
@@ -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 ###
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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 ###
|
||||||
28
server/migrations/versions/f8294b31f022_add_track_keys.py
Normal file
28
server/migrations/versions/f8294b31f022_add_track_keys.py
Normal file
@@ -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")
|
||||||
@@ -12,7 +12,6 @@ dependencies = [
|
|||||||
"requests>=2.31.0",
|
"requests>=2.31.0",
|
||||||
"aiortc>=1.5.0",
|
"aiortc>=1.5.0",
|
||||||
"sortedcontainers>=2.4.0",
|
"sortedcontainers>=2.4.0",
|
||||||
"loguru>=0.7.0",
|
|
||||||
"pydantic-settings>=2.0.2",
|
"pydantic-settings>=2.0.2",
|
||||||
"structlog>=23.1.0",
|
"structlog>=23.1.0",
|
||||||
"uvicorn[standard]>=0.23.1",
|
"uvicorn[standard]>=0.23.1",
|
||||||
@@ -27,7 +26,6 @@ dependencies = [
|
|||||||
"prometheus-fastapi-instrumentator>=6.1.0",
|
"prometheus-fastapi-instrumentator>=6.1.0",
|
||||||
"sentencepiece>=0.1.99",
|
"sentencepiece>=0.1.99",
|
||||||
"protobuf>=4.24.3",
|
"protobuf>=4.24.3",
|
||||||
"profanityfilter>=2.0.6",
|
|
||||||
"celery>=5.3.4",
|
"celery>=5.3.4",
|
||||||
"redis>=5.0.1",
|
"redis>=5.0.1",
|
||||||
"python-jose[cryptography]>=3.3.0",
|
"python-jose[cryptography]>=3.3.0",
|
||||||
@@ -40,6 +38,8 @@ dependencies = [
|
|||||||
"llama-index-llms-openai-like>=0.4.0",
|
"llama-index-llms-openai-like>=0.4.0",
|
||||||
"pytest-env>=1.1.5",
|
"pytest-env>=1.1.5",
|
||||||
"webvtt-py>=0.5.0",
|
"webvtt-py>=0.5.0",
|
||||||
|
"icalendar>=6.0.0",
|
||||||
|
"hatchet-sdk>=0.47.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -113,25 +113,27 @@ source = ["reflector"]
|
|||||||
[tool.pytest_env]
|
[tool.pytest_env]
|
||||||
ENVIRONMENT = "pytest"
|
ENVIRONMENT = "pytest"
|
||||||
DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test"
|
DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test"
|
||||||
|
AUTH_BACKEND = "jwt"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v"
|
addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
markers = [
|
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]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = [
|
||||||
"I", # isort - import sorting
|
"I", # isort - import sorting
|
||||||
"F401", # unused imports
|
"F401", # unused imports
|
||||||
|
"E402", # module level import not at top of file
|
||||||
"PLC0415", # import-outside-top-level - detect inline imports
|
"PLC0415", # import-outside-top-level - detect inline imports
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"reflector/processors/summary/summary_builder.py" = ["E501"]
|
"reflector/processors/summary/summary_builder.py" = ["E501"]
|
||||||
"gpu/**.py" = ["PLC0415"]
|
"gpu/modal_deployments/**.py" = ["PLC0415"]
|
||||||
"reflector/tools/**.py" = ["PLC0415"]
|
"reflector/tools/**.py" = ["PLC0415"]
|
||||||
"migrations/versions/**.py" = ["PLC0415"]
|
"migrations/versions/**.py" = ["PLC0415"]
|
||||||
"tests/**.py" = ["PLC0415"]
|
"tests/**.py" = ["PLC0415"]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
|
|||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
from reflector.metrics import metrics_init
|
from reflector.metrics import metrics_init
|
||||||
from reflector.settings import settings
|
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.meetings import router as meetings_router
|
||||||
from reflector.views.rooms import router as rooms_router
|
from reflector.views.rooms import router as rooms_router
|
||||||
from reflector.views.rtc_offer import router as rtc_offer_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_webrtc import router as transcripts_webrtc_router
|
||||||
from reflector.views.transcripts_websocket import router as transcripts_websocket_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 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.whereby import router as whereby_router
|
||||||
from reflector.views.zulip import router as zulip_router
|
from reflector.views.zulip import router as zulip_router
|
||||||
|
|
||||||
@@ -65,6 +68,12 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
# metrics
|
# metrics
|
||||||
instrumentator = Instrumentator(
|
instrumentator = Instrumentator(
|
||||||
excluded_handlers=["/docs", "/metrics"],
|
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_webrtc_router, prefix="/v1")
|
||||||
app.include_router(transcripts_process_router, prefix="/v1")
|
app.include_router(transcripts_process_router, prefix="/v1")
|
||||||
app.include_router(user_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(zulip_router, prefix="/v1")
|
||||||
app.include_router(whereby_router, prefix="/v1")
|
app.include_router(whereby_router, prefix="/v1")
|
||||||
|
app.include_router(daily_router, prefix="/v1/daily")
|
||||||
add_pagination(app)
|
add_pagination(app)
|
||||||
|
|
||||||
# prepare celery
|
# prepare celery
|
||||||
|
|||||||
33
server/reflector/asynctask.py
Normal file
33
server/reflector/asynctask.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from celery import current_task
|
||||||
|
|
||||||
|
from reflector.db import get_database
|
||||||
|
from reflector.llm import llm_session_id
|
||||||
|
|
||||||
|
|
||||||
|
def asynctask(f):
|
||||||
|
@functools.wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
async def run_with_db():
|
||||||
|
task_id = current_task.request.id if current_task else None
|
||||||
|
llm_session_id.set(task_id or f"random-{uuid4().hex}")
|
||||||
|
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
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
from typing import Annotated, Optional
|
from typing import Annotated, List, Optional
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from reflector.db.user_api_keys import user_api_keys_controller
|
||||||
|
from reflector.db.users import user_controller
|
||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
|
from reflector.utils import generate_uuid4
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
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_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read()
|
||||||
jwt_algorithm = settings.AUTH_JWT_ALGORITHM
|
jwt_algorithm = settings.AUTH_JWT_ALGORITHM
|
||||||
@@ -26,7 +30,7 @@ class JWTException(Exception):
|
|||||||
|
|
||||||
class UserInfo(BaseModel):
|
class UserInfo(BaseModel):
|
||||||
sub: str
|
sub: str
|
||||||
email: str
|
email: Optional[str] = None
|
||||||
|
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
return getattr(self, key)
|
return getattr(self, key)
|
||||||
@@ -58,33 +62,65 @@ def authenticated(token: Annotated[str, Depends(oauth2_scheme)]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def current_user(
|
async def _authenticate_user(
|
||||||
token: Annotated[Optional[str], Depends(oauth2_scheme)],
|
jwt_token: Optional[str],
|
||||||
jwtauth: JWTAuth = Depends(),
|
api_key: Optional[str],
|
||||||
):
|
jwtauth: JWTAuth,
|
||||||
if token is None:
|
) -> UserInfo | None:
|
||||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
user_infos: List[UserInfo] = []
|
||||||
try:
|
if api_key:
|
||||||
payload = jwtauth.verify_token(token)
|
user_api_key = await user_api_keys_controller.verify_key(api_key)
|
||||||
sub = payload["sub"]
|
if user_api_key:
|
||||||
return UserInfo(sub=sub)
|
user_infos.append(UserInfo(sub=user_api_key.user_id, email=None))
|
||||||
except JWTError as e:
|
|
||||||
logger.error(f"JWT error: {e}")
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
|
||||||
|
|
||||||
|
if jwt_token:
|
||||||
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:
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
payload = jwtauth.verify_token(token)
|
payload = jwtauth.verify_token(jwt_token)
|
||||||
sub = payload["sub"]
|
authentik_uid = payload["sub"]
|
||||||
email = payload["email"]
|
email = payload["email"]
|
||||||
return UserInfo(sub=sub, email=email)
|
|
||||||
|
user = await user_controller.get_by_authentik_uid(authentik_uid)
|
||||||
|
if not user:
|
||||||
|
logger.info(
|
||||||
|
f"Creating new user on first login: {authentik_uid} ({email})"
|
||||||
|
)
|
||||||
|
user = await user_controller.create_or_update(
|
||||||
|
id=generate_uuid4(),
|
||||||
|
authentik_uid=authentik_uid,
|
||||||
|
email=email,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_infos.append(UserInfo(sub=user.id, email=email))
|
||||||
except JWTError as e:
|
except JWTError as e:
|
||||||
logger.error(f"JWT error: {e}")
|
logger.error(f"JWT error: {e}")
|
||||||
raise HTTPException(status_code=401, detail="Invalid authentication")
|
raise HTTPException(status_code=401, detail="Invalid authentication")
|
||||||
|
|
||||||
|
if len(user_infos) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
6
server/reflector/dailyco_api/README.md
Normal file
6
server/reflector/dailyco_api/README.md
Normal file
@@ -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
|
||||||
110
server/reflector/dailyco_api/__init__.py
Normal file
110
server/reflector/dailyco_api/__init__.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""
|
||||||
|
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 (
|
||||||
|
FinishedRecordingResponse,
|
||||||
|
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,
|
||||||
|
DailyWebhookEventUnion,
|
||||||
|
ParticipantJoinedEvent,
|
||||||
|
ParticipantJoinedPayload,
|
||||||
|
ParticipantLeftEvent,
|
||||||
|
ParticipantLeftPayload,
|
||||||
|
RecordingErrorEvent,
|
||||||
|
RecordingErrorPayload,
|
||||||
|
RecordingReadyEvent,
|
||||||
|
RecordingReadyToDownloadPayload,
|
||||||
|
RecordingStartedEvent,
|
||||||
|
RecordingStartedPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Client
|
||||||
|
"DailyApiClient",
|
||||||
|
"DailyApiError",
|
||||||
|
# Requests
|
||||||
|
"CreateRoomRequest",
|
||||||
|
"RoomProperties",
|
||||||
|
"RecordingsBucketConfig",
|
||||||
|
"CreateMeetingTokenRequest",
|
||||||
|
"MeetingTokenProperties",
|
||||||
|
"CreateWebhookRequest",
|
||||||
|
"UpdateWebhookRequest",
|
||||||
|
# Responses
|
||||||
|
"RoomResponse",
|
||||||
|
"RoomPresenceResponse",
|
||||||
|
"RoomPresenceParticipant",
|
||||||
|
"MeetingParticipantsResponse",
|
||||||
|
"MeetingParticipant",
|
||||||
|
"MeetingResponse",
|
||||||
|
"RecordingResponse",
|
||||||
|
"FinishedRecordingResponse",
|
||||||
|
"RecordingS3Info",
|
||||||
|
"MeetingTokenResponse",
|
||||||
|
"WebhookResponse",
|
||||||
|
# Webhooks
|
||||||
|
"DailyWebhookEvent",
|
||||||
|
"DailyWebhookEventUnion",
|
||||||
|
"DailyTrack",
|
||||||
|
"ParticipantJoinedEvent",
|
||||||
|
"ParticipantJoinedPayload",
|
||||||
|
"ParticipantLeftEvent",
|
||||||
|
"ParticipantLeftPayload",
|
||||||
|
"RecordingStartedEvent",
|
||||||
|
"RecordingStartedPayload",
|
||||||
|
"RecordingReadyEvent",
|
||||||
|
"RecordingReadyToDownloadPayload",
|
||||||
|
"RecordingErrorEvent",
|
||||||
|
"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",
|
||||||
|
]
|
||||||
573
server/reflector/dailyco_api/client.py
Normal file
573
server/reflector/dailyco_api/client.py
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
"""
|
||||||
|
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}: {response.text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""
|
||||||
|
https://docs.daily.co/reference/rest-api/recordings/get-recording-information
|
||||||
|
Get recording metadata and status.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def list_recordings(
|
||||||
|
self,
|
||||||
|
room_name: NonEmptyString | None = None,
|
||||||
|
starting_after: str | None = None,
|
||||||
|
ending_before: str | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> list[RecordingResponse]:
|
||||||
|
"""
|
||||||
|
List recordings with optional filters.
|
||||||
|
|
||||||
|
Reference: https://docs.daily.co/reference/rest-api/recordings
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_name: Filter by room name
|
||||||
|
starting_after: Pagination cursor - recording ID to start after
|
||||||
|
ending_before: Pagination cursor - recording ID to end before
|
||||||
|
limit: Max results per page (default 100, max 100)
|
||||||
|
|
||||||
|
Note: starting_after/ending_before are pagination cursors (recording IDs),
|
||||||
|
NOT time filters. API returns recordings in reverse chronological order.
|
||||||
|
"""
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
params = {"limit": limit}
|
||||||
|
if room_name:
|
||||||
|
params["room_name"] = room_name
|
||||||
|
if starting_after:
|
||||||
|
params["starting_after"] = starting_after
|
||||||
|
if ending_before:
|
||||||
|
params["ending_before"] = ending_before
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.base_url}/recordings",
|
||||||
|
headers=self.headers,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await self._handle_response(response, "list_recordings")
|
||||||
|
|
||||||
|
if not isinstance(data, dict) or "data" not in data:
|
||||||
|
logger.error(
|
||||||
|
"Daily.co API returned unexpected format for list_recordings",
|
||||||
|
data_type=type(data).__name__,
|
||||||
|
data_keys=list(data.keys()) if isinstance(data, dict) else None,
|
||||||
|
data_sample=str(data)[:500],
|
||||||
|
room_name=room_name,
|
||||||
|
operation="list_recordings",
|
||||||
|
)
|
||||||
|
raise httpx.HTTPStatusError(
|
||||||
|
message=f"Unexpected response format from list_recordings: {type(data).__name__}",
|
||||||
|
request=response.request,
|
||||||
|
response=response,
|
||||||
|
)
|
||||||
|
|
||||||
|
return [RecordingResponse(**r) for r in data["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]
|
||||||
166
server/reflector/dailyco_api/requests.py
Normal file
166
server/reflector/dailyco_api/requests.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
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")
|
||||||
|
enable_knocking: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Enable knocking for private rooms (allows participants to request access)",
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
start_cloud_recording_opts: dict | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Options for startRecording when start_cloud_recording is true (e.g., maxDuration)",
|
||||||
|
)
|
||||||
|
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"
|
||||||
|
)
|
||||||
217
server/reflector/dailyco_api/responses.py
Normal file
217
server/reflector/dailyco_api/responses.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""
|
||||||
|
Daily.co API Response Models
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from reflector.dailyco_api.webhooks import DailyTrack
|
||||||
|
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 | None = Field(None, 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 (network layer).
|
||||||
|
|
||||||
|
Duration may be None for recordings still being processed by Daily.
|
||||||
|
Use FinishedRecordingResponse for recordings ready for processing.
|
||||||
|
|
||||||
|
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 | None = Field(
|
||||||
|
None, description="Maximum participants during recording (may be missing)"
|
||||||
|
)
|
||||||
|
duration: int | None = Field(
|
||||||
|
None, description="Recording duration in seconds (None if still processing)"
|
||||||
|
)
|
||||||
|
share_token: NonEmptyString | None = Field(
|
||||||
|
None, description="Token for sharing recording"
|
||||||
|
)
|
||||||
|
s3: RecordingS3Info | None = Field(None, description="S3 bucket information")
|
||||||
|
tracks: list[DailyTrack] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Track list for raw-tracks recordings (always array, never null)",
|
||||||
|
)
|
||||||
|
# this is not a mistake but a deliberate Daily.co naming decision
|
||||||
|
mtgSessionId: NonEmptyString | None = Field(
|
||||||
|
None, description="Meeting session identifier (may be missing)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_finished(self) -> "FinishedRecordingResponse | None":
|
||||||
|
"""Convert to FinishedRecordingResponse if duration is available and status is finished."""
|
||||||
|
if self.duration is None or self.status != "finished":
|
||||||
|
return None
|
||||||
|
return FinishedRecordingResponse(**self.model_dump())
|
||||||
|
|
||||||
|
|
||||||
|
class FinishedRecordingResponse(RecordingResponse):
|
||||||
|
"""
|
||||||
|
Recording with confirmed duration - ready for processing.
|
||||||
|
|
||||||
|
This model guarantees duration is present and status is finished.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: Literal["finished"] = Field(
|
||||||
|
description="Recording status (always 'finished')"
|
||||||
|
)
|
||||||
|
duration: int = Field(description="Recording duration in seconds")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
228
server/reflector/dailyco_api/webhook_utils.py
Normal file
228
server/reflector/dailyco_api/webhook_utils.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
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_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
|
||||||
271
server/reflector/dailyco_api/webhooks.py
Normal file
271
server/reflector/dailyco_api/webhooks.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Daily.co Webhook Event Models
|
||||||
|
|
||||||
|
Reference: https://docs.daily.co/reference/rest-api/webhooks
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated, Any, Dict, Literal, Union
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantJoinedEvent(BaseModel):
|
||||||
|
version: NonEmptyString
|
||||||
|
type: Literal["participant.joined"]
|
||||||
|
id: NonEmptyString
|
||||||
|
payload: ParticipantJoinedPayload
|
||||||
|
event_ts: int
|
||||||
|
|
||||||
|
_normalize_event_ts = field_validator("event_ts", mode="before")(
|
||||||
|
normalize_timestamp_to_int
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantLeftEvent(BaseModel):
|
||||||
|
version: NonEmptyString
|
||||||
|
type: Literal["participant.left"]
|
||||||
|
id: NonEmptyString
|
||||||
|
payload: ParticipantLeftPayload
|
||||||
|
event_ts: int
|
||||||
|
|
||||||
|
_normalize_event_ts = field_validator("event_ts", mode="before")(
|
||||||
|
normalize_timestamp_to_int
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingStartedEvent(BaseModel):
|
||||||
|
version: NonEmptyString
|
||||||
|
type: Literal["recording.started"]
|
||||||
|
id: NonEmptyString
|
||||||
|
payload: RecordingStartedPayload
|
||||||
|
event_ts: int
|
||||||
|
|
||||||
|
_normalize_event_ts = field_validator("event_ts", mode="before")(
|
||||||
|
normalize_timestamp_to_int
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingReadyEvent(BaseModel):
|
||||||
|
version: NonEmptyString
|
||||||
|
type: Literal["recording.ready-to-download"]
|
||||||
|
id: NonEmptyString
|
||||||
|
payload: RecordingReadyToDownloadPayload
|
||||||
|
event_ts: int
|
||||||
|
|
||||||
|
_normalize_event_ts = field_validator("event_ts", mode="before")(
|
||||||
|
normalize_timestamp_to_int
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingErrorEvent(BaseModel):
|
||||||
|
version: NonEmptyString
|
||||||
|
type: Literal["recording.error"]
|
||||||
|
id: NonEmptyString
|
||||||
|
payload: RecordingErrorPayload
|
||||||
|
event_ts: int
|
||||||
|
|
||||||
|
_normalize_event_ts = field_validator("event_ts", mode="before")(
|
||||||
|
normalize_timestamp_to_int
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DailyWebhookEventUnion = Annotated[
|
||||||
|
Union[
|
||||||
|
ParticipantJoinedEvent,
|
||||||
|
ParticipantLeftEvent,
|
||||||
|
RecordingStartedEvent,
|
||||||
|
RecordingReadyEvent,
|
||||||
|
RecordingErrorEvent,
|
||||||
|
],
|
||||||
|
Field(discriminator="type"),
|
||||||
|
]
|
||||||
@@ -24,10 +24,14 @@ def get_database() -> databases.Database:
|
|||||||
|
|
||||||
|
|
||||||
# import models
|
# import models
|
||||||
|
import reflector.db.calendar_events # noqa
|
||||||
|
import reflector.db.daily_participant_sessions # noqa
|
||||||
import reflector.db.meetings # noqa
|
import reflector.db.meetings # noqa
|
||||||
import reflector.db.recordings # noqa
|
import reflector.db.recordings # noqa
|
||||||
import reflector.db.rooms # noqa
|
import reflector.db.rooms # noqa
|
||||||
import reflector.db.transcripts # noqa
|
import reflector.db.transcripts # noqa
|
||||||
|
import reflector.db.user_api_keys # noqa
|
||||||
|
import reflector.db.users # noqa
|
||||||
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if "postgres" not in settings.DATABASE_URL:
|
if "postgres" not in settings.DATABASE_URL:
|
||||||
|
|||||||
187
server/reflector/db/calendar_events.py
Normal file
187
server/reflector/db/calendar_events.py
Normal file
@@ -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()
|
||||||
229
server/reflector/db/daily_participant_sessions.py
Normal file
229
server/reflector/db/daily_participant_sessions.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""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]
|
||||||
|
|
||||||
|
async def get_all_sessions_for_meeting(
|
||||||
|
self, meeting_id: NonEmptyString
|
||||||
|
) -> dict[NonEmptyString, DailyParticipantSession]:
|
||||||
|
query = daily_participant_sessions.select().where(
|
||||||
|
daily_participant_sessions.c.meeting_id == meeting_id
|
||||||
|
)
|
||||||
|
results = await get_database().fetch_all(query)
|
||||||
|
# TODO DailySessionId custom type
|
||||||
|
return {row["session_id"]: DailyParticipantSession(**row) for row in results}
|
||||||
|
|
||||||
|
async def batch_upsert_sessions(
|
||||||
|
self, sessions: list[DailyParticipantSession]
|
||||||
|
) -> None:
|
||||||
|
"""Upsert multiple sessions in single query.
|
||||||
|
|
||||||
|
Uses ON CONFLICT for idempotency. Updates user_name on conflict since they may change it during a meeting.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not sessions:
|
||||||
|
return
|
||||||
|
|
||||||
|
values = [session.model_dump() for session in sessions]
|
||||||
|
query = insert(daily_participant_sessions).values(values)
|
||||||
|
query = query.on_conflict_do_update(
|
||||||
|
index_elements=["id"],
|
||||||
|
set_={
|
||||||
|
# Preserve existing left_at to prevent race conditions
|
||||||
|
"left_at": sa.func.coalesce(
|
||||||
|
daily_participant_sessions.c.left_at,
|
||||||
|
query.excluded.left_at,
|
||||||
|
),
|
||||||
|
"user_name": query.excluded.user_name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await get_database().execute(query)
|
||||||
|
|
||||||
|
async def batch_close_sessions(
|
||||||
|
self, session_ids: list[NonEmptyString], left_at: datetime
|
||||||
|
) -> None:
|
||||||
|
"""Mark multiple sessions as left in single query.
|
||||||
|
|
||||||
|
Only updates sessions where left_at is NULL (protects already-closed sessions).
|
||||||
|
|
||||||
|
Left_at mismatch for existing sessions is ignored, assumed to be not important issue if ever happens.
|
||||||
|
"""
|
||||||
|
if not session_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
query = (
|
||||||
|
daily_participant_sessions.update()
|
||||||
|
.where(
|
||||||
|
sa.and_(
|
||||||
|
daily_participant_sessions.c.id.in_(session_ids),
|
||||||
|
daily_participant_sessions.c.left_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(left_at=left_at)
|
||||||
|
)
|
||||||
|
await get_database().execute(query)
|
||||||
|
|
||||||
|
|
||||||
|
daily_participant_sessions_controller = DailyParticipantSessionController()
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from fastapi import HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from reflector.db import get_database, metadata
|
from reflector.db import get_database, metadata
|
||||||
from reflector.db.rooms import Room
|
from reflector.db.rooms import Room
|
||||||
|
from reflector.schemas.platform import WHEREBY_PLATFORM, Platform
|
||||||
from reflector.utils import generate_uuid4
|
from reflector.utils import generate_uuid4
|
||||||
|
from reflector.utils.string import assert_equal
|
||||||
|
|
||||||
meetings = sa.Table(
|
meetings = sa.Table(
|
||||||
"meeting",
|
"meeting",
|
||||||
@@ -18,8 +20,12 @@ meetings = sa.Table(
|
|||||||
sa.Column("host_room_url", sa.String),
|
sa.Column("host_room_url", sa.String),
|
||||||
sa.Column("start_date", sa.DateTime(timezone=True)),
|
sa.Column("start_date", sa.DateTime(timezone=True)),
|
||||||
sa.Column("end_date", sa.DateTime(timezone=True)),
|
sa.Column("end_date", sa.DateTime(timezone=True)),
|
||||||
sa.Column("user_id", sa.String),
|
sa.Column(
|
||||||
sa.Column("room_id", sa.String),
|
"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("is_locked", sa.Boolean, nullable=False, server_default=sa.false()),
|
||||||
sa.Column("room_mode", sa.String, nullable=False, server_default="normal"),
|
sa.Column("room_mode", sa.String, nullable=False, server_default="normal"),
|
||||||
sa.Column("recording_type", sa.String, nullable=False, server_default="cloud"),
|
sa.Column("recording_type", sa.String, nullable=False, server_default="cloud"),
|
||||||
@@ -41,20 +47,36 @@ meetings = sa.Table(
|
|||||||
nullable=False,
|
nullable=False,
|
||||||
server_default=sa.true(),
|
server_default=sa.true(),
|
||||||
),
|
),
|
||||||
sa.Index("idx_meeting_room_id", "room_id"),
|
sa.Column(
|
||||||
sa.Index(
|
"calendar_event_id",
|
||||||
"idx_one_active_meeting_per_room",
|
sa.String,
|
||||||
"room_id",
|
sa.ForeignKey(
|
||||||
unique=True,
|
"calendar_event.id",
|
||||||
postgresql_where=sa.text("is_active = true"),
|
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 = sa.Table(
|
||||||
"meeting_consent",
|
"meeting_consent",
|
||||||
metadata,
|
metadata,
|
||||||
sa.Column("id", sa.String, primary_key=True),
|
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("user_id", sa.String),
|
||||||
sa.Column("consent_given", sa.Boolean, nullable=False),
|
sa.Column("consent_given", sa.Boolean, nullable=False),
|
||||||
sa.Column("consent_timestamp", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("consent_timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||||
@@ -76,15 +98,18 @@ class Meeting(BaseModel):
|
|||||||
host_room_url: str
|
host_room_url: str
|
||||||
start_date: datetime
|
start_date: datetime
|
||||||
end_date: datetime
|
end_date: datetime
|
||||||
user_id: str | None = None
|
room_id: str | None
|
||||||
room_id: str | None = None
|
|
||||||
is_locked: bool = False
|
is_locked: bool = False
|
||||||
room_mode: Literal["normal", "group"] = "normal"
|
room_mode: Literal["normal", "group"] = "normal"
|
||||||
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
||||||
recording_trigger: Literal[
|
recording_trigger: Literal[ # whereby-specific
|
||||||
"none", "prompt", "automatic", "automatic-2nd-participant"
|
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||||
] = "automatic-2nd-participant"
|
] = "automatic-2nd-participant"
|
||||||
num_clients: int = 0
|
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:
|
class MeetingController:
|
||||||
@@ -96,12 +121,10 @@ class MeetingController:
|
|||||||
host_room_url: str,
|
host_room_url: str,
|
||||||
start_date: datetime,
|
start_date: datetime,
|
||||||
end_date: datetime,
|
end_date: datetime,
|
||||||
user_id: str,
|
|
||||||
room: Room,
|
room: Room,
|
||||||
|
calendar_event_id: str | None = None,
|
||||||
|
calendar_metadata: dict[str, Any] | None = None,
|
||||||
):
|
):
|
||||||
"""
|
|
||||||
Create a new meeting
|
|
||||||
"""
|
|
||||||
meeting = Meeting(
|
meeting = Meeting(
|
||||||
id=id,
|
id=id,
|
||||||
room_name=room_name,
|
room_name=room_name,
|
||||||
@@ -109,41 +132,49 @@ class MeetingController:
|
|||||||
host_room_url=host_room_url,
|
host_room_url=host_room_url,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
user_id=user_id,
|
|
||||||
room_id=room.id,
|
room_id=room.id,
|
||||||
is_locked=room.is_locked,
|
is_locked=room.is_locked,
|
||||||
room_mode=room.room_mode,
|
room_mode=room.room_mode,
|
||||||
recording_type=room.recording_type,
|
recording_type=room.recording_type,
|
||||||
recording_trigger=room.recording_trigger,
|
recording_trigger=room.recording_trigger,
|
||||||
|
calendar_event_id=calendar_event_id,
|
||||||
|
calendar_metadata=calendar_metadata,
|
||||||
|
platform=room.platform,
|
||||||
)
|
)
|
||||||
query = meetings.insert().values(**meeting.model_dump())
|
query = meetings.insert().values(**meeting.model_dump())
|
||||||
await get_database().execute(query)
|
await get_database().execute(query)
|
||||||
return meeting
|
return meeting
|
||||||
|
|
||||||
async def get_all_active(self) -> list[Meeting]:
|
async def get_all_active(self, platform: str | None = None) -> list[Meeting]:
|
||||||
"""
|
conditions = [meetings.c.is_active]
|
||||||
Get active meetings.
|
if platform is not None:
|
||||||
"""
|
conditions.append(meetings.c.platform == platform)
|
||||||
query = meetings.select().where(meetings.c.is_active)
|
query = meetings.select().where(sa.and_(*conditions))
|
||||||
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(
|
async def get_by_room_name(
|
||||||
self,
|
self,
|
||||||
room_name: str,
|
room_name: str,
|
||||||
) -> Meeting:
|
) -> Meeting | None:
|
||||||
"""
|
"""
|
||||||
Get a meeting by room name.
|
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)
|
result = await get_database().fetch_one(query)
|
||||||
if not result:
|
if not result:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return Meeting(**result)
|
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.
|
Get latest active meeting for a room.
|
||||||
|
For backward compatibility, returns the most recent active meeting.
|
||||||
"""
|
"""
|
||||||
end_date = getattr(meetings.c, "end_date")
|
end_date = getattr(meetings.c, "end_date")
|
||||||
query = (
|
query = (
|
||||||
@@ -160,40 +191,97 @@ class MeetingController:
|
|||||||
result = await get_database().fetch_one(query)
|
result = await get_database().fetch_one(query)
|
||||||
if not result:
|
if not result:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return Meeting(**result)
|
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)
|
result = await get_database().fetch_one(query)
|
||||||
if not result:
|
if not result:
|
||||||
return None
|
return None
|
||||||
return Meeting(**result)
|
return Meeting(**result)
|
||||||
|
|
||||||
async def get_by_id_for_http(self, meeting_id: str, user_id: str | None) -> Meeting:
|
async def get_by_id(
|
||||||
"""
|
self, meeting_id: str, room: Room | None = None
|
||||||
Get a meeting by ID for HTTP request.
|
) -> Meeting | None:
|
||||||
|
|
||||||
If not found, it will raise a 404 error.
|
|
||||||
"""
|
|
||||||
query = meetings.select().where(meetings.c.id == meeting_id)
|
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)
|
result = await get_database().fetch_one(query)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
return None
|
||||||
|
return Meeting(**result)
|
||||||
|
|
||||||
meeting = Meeting(**result)
|
async def get_by_calendar_event(
|
||||||
if result["user_id"] != user_id:
|
self, calendar_event_id: str, room: Room
|
||||||
meeting.host_room_url = ""
|
) -> Meeting | None:
|
||||||
|
query = meetings.select().where(
|
||||||
return meeting
|
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):
|
async def update_meeting(self, meeting_id: str, **kwargs):
|
||||||
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
|
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
|
||||||
await get_database().execute(query)
|
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:
|
class MeetingConsentController:
|
||||||
async def get_by_meeting_id(self, meeting_id: str) -> list[MeetingConsent]:
|
async def get_by_meeting_id(self, meeting_id: str) -> list[MeetingConsent]:
|
||||||
@@ -214,10 +302,9 @@ class MeetingConsentController:
|
|||||||
result = await get_database().fetch_one(query)
|
result = await get_database().fetch_one(query)
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
return MeetingConsent(**result) if result else None
|
return MeetingConsent(**result)
|
||||||
|
|
||||||
async def upsert(self, consent: MeetingConsent) -> MeetingConsent:
|
async def upsert(self, consent: MeetingConsent) -> MeetingConsent:
|
||||||
"""Create new consent or update existing one for authenticated users"""
|
|
||||||
if consent.user_id:
|
if consent.user_id:
|
||||||
# For authenticated users, check if consent already exists
|
# For authenticated users, check if consent already exists
|
||||||
# not transactional but we're ok with that; the consents ain't deleted anyways
|
# not transactional but we're ok with that; the consents ain't deleted anyways
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Literal
|
|||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
from reflector.db import get_database, metadata
|
from reflector.db import get_database, metadata
|
||||||
from reflector.utils import generate_uuid4
|
from reflector.utils import generate_uuid4
|
||||||
@@ -21,6 +22,7 @@ recordings = sa.Table(
|
|||||||
server_default="pending",
|
server_default="pending",
|
||||||
),
|
),
|
||||||
sa.Column("meeting_id", sa.String),
|
sa.Column("meeting_id", sa.String),
|
||||||
|
sa.Column("track_keys", sa.JSON, nullable=True),
|
||||||
sa.Index("idx_recording_meeting_id", "meeting_id"),
|
sa.Index("idx_recording_meeting_id", "meeting_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,10 +30,20 @@ recordings = sa.Table(
|
|||||||
class Recording(BaseModel):
|
class Recording(BaseModel):
|
||||||
id: str = Field(default_factory=generate_uuid4)
|
id: str = Field(default_factory=generate_uuid4)
|
||||||
bucket_name: str
|
bucket_name: str
|
||||||
|
# for single-track
|
||||||
object_key: str
|
object_key: str
|
||||||
recorded_at: datetime
|
recorded_at: datetime
|
||||||
status: Literal["pending", "processing", "completed", "failed"] = "pending"
|
status: Literal["pending", "processing", "completed", "failed"] = "pending"
|
||||||
meeting_id: str | None = None
|
meeting_id: str | None = None
|
||||||
|
# for multitrack reprocessing
|
||||||
|
# track_keys can be empty list [] if recording finished but no audio was captured (silence/muted)
|
||||||
|
# None means not a multitrack recording, [] means multitrack with no tracks
|
||||||
|
track_keys: list[str] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_multitrack(self) -> bool:
|
||||||
|
"""True if recording has separate audio tracks (1+ tracks counts as multitrack)."""
|
||||||
|
return self.track_keys is not None and len(self.track_keys) > 0
|
||||||
|
|
||||||
|
|
||||||
class RecordingController:
|
class RecordingController:
|
||||||
@@ -40,12 +52,14 @@ class RecordingController:
|
|||||||
await get_database().execute(query)
|
await get_database().execute(query)
|
||||||
return recording
|
return recording
|
||||||
|
|
||||||
async def get_by_id(self, id: str) -> Recording:
|
async def get_by_id(self, id: str) -> Recording | None:
|
||||||
query = recordings.select().where(recordings.c.id == id)
|
query = recordings.select().where(recordings.c.id == id)
|
||||||
result = await get_database().fetch_one(query)
|
result = await get_database().fetch_one(query)
|
||||||
return Recording(**result) if result else None
|
return Recording(**result) if result else None
|
||||||
|
|
||||||
async def get_by_object_key(self, bucket_name: str, object_key: str) -> Recording:
|
async def get_by_object_key(
|
||||||
|
self, bucket_name: str, object_key: str
|
||||||
|
) -> Recording | None:
|
||||||
query = recordings.select().where(
|
query = recordings.select().where(
|
||||||
recordings.c.bucket_name == bucket_name,
|
recordings.c.bucket_name == bucket_name,
|
||||||
recordings.c.object_key == object_key,
|
recordings.c.object_key == object_key,
|
||||||
@@ -57,5 +71,44 @@ class RecordingController:
|
|||||||
query = recordings.delete().where(recordings.c.id == id)
|
query = recordings.delete().where(recordings.c.id == id)
|
||||||
await get_database().execute(query)
|
await get_database().execute(query)
|
||||||
|
|
||||||
|
# no check for existence
|
||||||
|
async def get_by_ids(self, recording_ids: list[str]) -> list[Recording]:
|
||||||
|
if not recording_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query = recordings.select().where(recordings.c.id.in_(recording_ids))
|
||||||
|
results = await get_database().fetch_all(query)
|
||||||
|
return [Recording(**row) for row in results]
|
||||||
|
|
||||||
|
async def get_multitrack_needing_reprocessing(
|
||||||
|
self, bucket_name: str
|
||||||
|
) -> list[Recording]:
|
||||||
|
"""
|
||||||
|
Get multitrack recordings that need reprocessing:
|
||||||
|
- Have track_keys (multitrack)
|
||||||
|
- Either have no transcript OR transcript has error status
|
||||||
|
|
||||||
|
This is more efficient than fetching all recordings and filtering in Python.
|
||||||
|
"""
|
||||||
|
from reflector.db.transcripts import (
|
||||||
|
transcripts, # noqa: PLC0415 cyclic import
|
||||||
|
)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
recordings.select()
|
||||||
|
.outerjoin(transcripts, recordings.c.id == transcripts.c.recording_id)
|
||||||
|
.where(
|
||||||
|
recordings.c.bucket_name == bucket_name,
|
||||||
|
recordings.c.track_keys.isnot(None),
|
||||||
|
or_(
|
||||||
|
transcripts.c.id.is_(None),
|
||||||
|
transcripts.c.status == "error",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
results = await get_database().fetch_all(query)
|
||||||
|
recordings_list = [Recording(**row) for row in results]
|
||||||
|
return [r for r in recordings_list if r.is_multitrack]
|
||||||
|
|
||||||
|
|
||||||
recordings_controller = RecordingController()
|
recordings_controller = RecordingController()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import secrets
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlite3 import IntegrityError
|
from sqlite3 import IntegrityError
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@@ -8,6 +9,8 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy.sql import false, or_
|
from sqlalchemy.sql import false, or_
|
||||||
|
|
||||||
from reflector.db import get_database, metadata
|
from reflector.db import get_database, metadata
|
||||||
|
from reflector.schemas.platform import Platform
|
||||||
|
from reflector.settings import settings
|
||||||
from reflector.utils import generate_uuid4
|
from reflector.utils import generate_uuid4
|
||||||
|
|
||||||
rooms = sqlalchemy.Table(
|
rooms = sqlalchemy.Table(
|
||||||
@@ -40,7 +43,34 @@ rooms = sqlalchemy.Table(
|
|||||||
sqlalchemy.Column(
|
sqlalchemy.Column(
|
||||||
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
|
"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=False,
|
||||||
|
),
|
||||||
|
sqlalchemy.Column(
|
||||||
|
"use_hatchet",
|
||||||
|
sqlalchemy.Boolean,
|
||||||
|
nullable=False,
|
||||||
|
server_default=false(),
|
||||||
|
),
|
||||||
|
sqlalchemy.Column(
|
||||||
|
"skip_consent",
|
||||||
|
sqlalchemy.Boolean,
|
||||||
|
nullable=False,
|
||||||
|
server_default=sqlalchemy.sql.false(),
|
||||||
|
),
|
||||||
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
||||||
|
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,10 +85,20 @@ class Room(BaseModel):
|
|||||||
is_locked: bool = False
|
is_locked: bool = False
|
||||||
room_mode: Literal["normal", "group"] = "normal"
|
room_mode: Literal["normal", "group"] = "normal"
|
||||||
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
||||||
recording_trigger: Literal[
|
recording_trigger: Literal[ # whereby-specific
|
||||||
"none", "prompt", "automatic", "automatic-2nd-participant"
|
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||||
] = "automatic-2nd-participant"
|
] = "automatic-2nd-participant"
|
||||||
is_shared: bool = False
|
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 = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
|
||||||
|
use_hatchet: bool = False
|
||||||
|
skip_consent: bool = False
|
||||||
|
|
||||||
|
|
||||||
class RoomController:
|
class RoomController:
|
||||||
@@ -107,22 +147,41 @@ class RoomController:
|
|||||||
recording_type: str,
|
recording_type: str,
|
||||||
recording_trigger: str,
|
recording_trigger: str,
|
||||||
is_shared: bool,
|
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 = settings.DEFAULT_VIDEO_PLATFORM,
|
||||||
|
skip_consent: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add a new room
|
Add a new room
|
||||||
"""
|
"""
|
||||||
room = Room(
|
if webhook_url and not webhook_secret:
|
||||||
name=name,
|
webhook_secret = secrets.token_urlsafe(32)
|
||||||
user_id=user_id,
|
|
||||||
zulip_auto_post=zulip_auto_post,
|
room_data = {
|
||||||
zulip_stream=zulip_stream,
|
"name": name,
|
||||||
zulip_topic=zulip_topic,
|
"user_id": user_id,
|
||||||
is_locked=is_locked,
|
"zulip_auto_post": zulip_auto_post,
|
||||||
room_mode=room_mode,
|
"zulip_stream": zulip_stream,
|
||||||
recording_type=recording_type,
|
"zulip_topic": zulip_topic,
|
||||||
recording_trigger=recording_trigger,
|
"is_locked": is_locked,
|
||||||
is_shared=is_shared,
|
"room_mode": room_mode,
|
||||||
)
|
"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,
|
||||||
|
"skip_consent": skip_consent,
|
||||||
|
}
|
||||||
|
|
||||||
|
room = Room(**room_data)
|
||||||
query = rooms.insert().values(**room.model_dump())
|
query = rooms.insert().values(**room.model_dump())
|
||||||
try:
|
try:
|
||||||
await get_database().execute(query)
|
await get_database().execute(query)
|
||||||
@@ -134,6 +193,9 @@ class RoomController:
|
|||||||
"""
|
"""
|
||||||
Update a room fields with key/values in values
|
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)
|
query = rooms.update().where(rooms.c.id == room.id).values(**values)
|
||||||
try:
|
try:
|
||||||
await get_database().execute(query)
|
await get_database().execute(query)
|
||||||
@@ -183,6 +245,13 @@ class RoomController:
|
|||||||
|
|
||||||
return room
|
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(
|
async def remove_by_id(
|
||||||
self,
|
self,
|
||||||
room_id: str,
|
room_id: str,
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ from typing import Annotated, Any, Dict, Iterator
|
|||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
import webvtt
|
import webvtt
|
||||||
|
from databases.interfaces import Record as DbRecord
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
Field,
|
Field,
|
||||||
NonNegativeFloat,
|
NonNegativeFloat,
|
||||||
NonNegativeInt,
|
NonNegativeInt,
|
||||||
|
TypeAdapter,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
constr,
|
constr,
|
||||||
field_serializer,
|
field_serializer,
|
||||||
@@ -21,9 +23,10 @@ from pydantic import (
|
|||||||
|
|
||||||
from reflector.db import get_database
|
from reflector.db import get_database
|
||||||
from reflector.db.rooms import rooms
|
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.db.utils import is_postgresql
|
||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
|
from reflector.utils.string import NonEmptyString, try_parse_non_empty_string
|
||||||
|
|
||||||
DEFAULT_SEARCH_LIMIT = 20
|
DEFAULT_SEARCH_LIMIT = 20
|
||||||
SNIPPET_CONTEXT_LENGTH = 50 # Characters before/after match to include
|
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)
|
DEFAULT_MAX_SNIPPETS = NonNegativeInt(3)
|
||||||
LONG_SUMMARY_MAX_SNIPPETS = 2
|
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)]
|
SearchLimitBase = Annotated[int, Field(ge=1, le=100)]
|
||||||
SearchOffsetBase = Annotated[int, Field(ge=0)]
|
SearchOffsetBase = Annotated[int, Field(ge=0)]
|
||||||
SearchTotalBase = Annotated[int, Field(ge=0)]
|
SearchTotalBase = Annotated[int, Field(ge=0)]
|
||||||
|
|
||||||
SearchQuery = Annotated[SearchQueryBase, Field(description="Search query text")]
|
SearchQuery = Annotated[SearchQueryBase, Field(description="Search query text")]
|
||||||
|
search_query_adapter = TypeAdapter(SearchQuery)
|
||||||
SearchLimit = Annotated[SearchLimitBase, Field(description="Results per page")]
|
SearchLimit = Annotated[SearchLimitBase, Field(description="Results per page")]
|
||||||
SearchOffset = Annotated[
|
SearchOffset = Annotated[
|
||||||
SearchOffsetBase, Field(description="Number of results to skip")
|
SearchOffsetBase, Field(description="Number of results to skip")
|
||||||
@@ -88,7 +92,7 @@ class WebVTTProcessor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_snippets(
|
def generate_snippets(
|
||||||
webvtt_content: WebVTTContent,
|
webvtt_content: WebVTTContent,
|
||||||
query: str,
|
query: SearchQuery,
|
||||||
max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Generate snippets from WebVTT content."""
|
"""Generate snippets from WebVTT content."""
|
||||||
@@ -125,12 +129,14 @@ class SnippetCandidate:
|
|||||||
class SearchParameters(BaseModel):
|
class SearchParameters(BaseModel):
|
||||||
"""Validated search parameters for full-text search."""
|
"""Validated search parameters for full-text search."""
|
||||||
|
|
||||||
query_text: SearchQuery
|
query_text: SearchQuery | None = None
|
||||||
limit: SearchLimit = DEFAULT_SEARCH_LIMIT
|
limit: SearchLimit = DEFAULT_SEARCH_LIMIT
|
||||||
offset: SearchOffset = 0
|
offset: SearchOffset = 0
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
room_id: str | None = None
|
room_id: str | None = None
|
||||||
source_kind: SourceKind | None = None
|
source_kind: SourceKind | None = None
|
||||||
|
from_datetime: datetime | None = None
|
||||||
|
to_datetime: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class SearchResultDB(BaseModel):
|
class SearchResultDB(BaseModel):
|
||||||
@@ -157,7 +163,7 @@ class SearchResult(BaseModel):
|
|||||||
room_name: str | None = None
|
room_name: str | None = None
|
||||||
source_kind: SourceKind
|
source_kind: SourceKind
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
status: str = Field(..., min_length=1)
|
status: TranscriptStatus = Field(..., min_length=1)
|
||||||
rank: float = Field(..., ge=0, le=1)
|
rank: float = Field(..., ge=0, le=1)
|
||||||
duration: NonNegativeFloat | None = Field(..., description="Duration in seconds")
|
duration: NonNegativeFloat | None = Field(..., description="Duration in seconds")
|
||||||
search_snippets: list[str] = Field(
|
search_snippets: list[str] = Field(
|
||||||
@@ -199,15 +205,13 @@ class SnippetGenerator:
|
|||||||
prev_start = start
|
prev_start = start
|
||||||
|
|
||||||
@staticmethod
|
@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."""
|
"""Count total number of matches for a query in text."""
|
||||||
ZERO = NonNegativeInt(0)
|
ZERO = NonNegativeInt(0)
|
||||||
if not text:
|
if not text:
|
||||||
logger.warning("Empty text for search query in count_matches")
|
logger.warning("Empty text for search query in count_matches")
|
||||||
return ZERO
|
return ZERO
|
||||||
if not query:
|
assert query is not None
|
||||||
logger.warning("Empty query for search text in count_matches")
|
|
||||||
return ZERO
|
|
||||||
return NonNegativeInt(
|
return NonNegativeInt(
|
||||||
sum(1 for _ in SnippetGenerator.find_all_matches(text, query))
|
sum(1 for _ in SnippetGenerator.find_all_matches(text, query))
|
||||||
)
|
)
|
||||||
@@ -243,13 +247,14 @@ class SnippetGenerator:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def generate(
|
def generate(
|
||||||
text: str,
|
text: str,
|
||||||
query: str,
|
query: SearchQuery,
|
||||||
max_length: NonNegativeInt = DEFAULT_SNIPPET_MAX_LENGTH,
|
max_length: NonNegativeInt = DEFAULT_SNIPPET_MAX_LENGTH,
|
||||||
max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
max_snippets: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Generate snippets from text."""
|
"""Generate snippets from text."""
|
||||||
if not text or not query:
|
assert query is not None
|
||||||
logger.warning("Empty text or query for generate_snippets")
|
if not text:
|
||||||
|
logger.warning("Empty text for generate_snippets")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
candidates = (
|
candidates = (
|
||||||
@@ -270,7 +275,7 @@ class SnippetGenerator:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def from_summary(
|
def from_summary(
|
||||||
summary: str,
|
summary: str,
|
||||||
query: str,
|
query: SearchQuery,
|
||||||
max_snippets: NonNegativeInt = LONG_SUMMARY_MAX_SNIPPETS,
|
max_snippets: NonNegativeInt = LONG_SUMMARY_MAX_SNIPPETS,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Generate snippets from summary text."""
|
"""Generate snippets from summary text."""
|
||||||
@@ -278,9 +283,9 @@ class SnippetGenerator:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def combine_sources(
|
def combine_sources(
|
||||||
summary: str | None,
|
summary: NonEmptyString | None,
|
||||||
webvtt: WebVTTContent | None,
|
webvtt: WebVTTContent | None,
|
||||||
query: str,
|
query: SearchQuery,
|
||||||
max_total: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
max_total: NonNegativeInt = DEFAULT_MAX_SNIPPETS,
|
||||||
) -> tuple[list[str], NonNegativeInt]:
|
) -> tuple[list[str], NonNegativeInt]:
|
||||||
"""Combine snippets from multiple sources and return total match count.
|
"""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
|
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
|
webvtt_matches = 0
|
||||||
summary_matches = 0
|
summary_matches = 0
|
||||||
|
|
||||||
@@ -355,8 +365,8 @@ class SearchController:
|
|||||||
else_=rooms.c.name,
|
else_=rooms.c.name,
|
||||||
).label("room_name"),
|
).label("room_name"),
|
||||||
]
|
]
|
||||||
|
search_query = None
|
||||||
if params.query_text:
|
if params.query_text is not None:
|
||||||
search_query = sqlalchemy.func.websearch_to_tsquery(
|
search_query = sqlalchemy.func.websearch_to_tsquery(
|
||||||
"english", params.query_text
|
"english", params.query_text
|
||||||
)
|
)
|
||||||
@@ -373,21 +383,37 @@ class SearchController:
|
|||||||
transcripts.join(rooms, transcripts.c.room_id == rooms.c.id, isouter=True)
|
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(
|
base_query = base_query.where(
|
||||||
transcripts.c.search_vector_en.op("@@")(search_query)
|
transcripts.c.search_vector_en.op("@@")(search_query)
|
||||||
)
|
)
|
||||||
|
|
||||||
if params.user_id:
|
if params.user_id:
|
||||||
base_query = base_query.where(transcripts.c.user_id == params.user_id)
|
base_query = base_query.where(
|
||||||
|
sqlalchemy.or_(
|
||||||
|
transcripts.c.user_id == params.user_id, rooms.c.is_shared
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
base_query = base_query.where(rooms.c.is_shared)
|
||||||
if params.room_id:
|
if params.room_id:
|
||||||
base_query = base_query.where(transcripts.c.room_id == params.room_id)
|
base_query = base_query.where(transcripts.c.room_id == params.room_id)
|
||||||
if params.source_kind:
|
if params.source_kind:
|
||||||
base_query = base_query.where(
|
base_query = base_query.where(
|
||||||
transcripts.c.source_kind == params.source_kind
|
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"))
|
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
|
||||||
else:
|
else:
|
||||||
order_by = sqlalchemy.desc(transcripts.c.created_at)
|
order_by = sqlalchemy.desc(transcripts.c.created_at)
|
||||||
@@ -401,20 +427,30 @@ class SearchController:
|
|||||||
)
|
)
|
||||||
total = await get_database().fetch_val(count_query)
|
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)
|
r_dict: Dict[str, Any] = dict(r)
|
||||||
|
|
||||||
webvtt_raw: str | None = r_dict.pop("webvtt", None)
|
webvtt_raw: str | None = r_dict.pop("webvtt", None)
|
||||||
|
webvtt: WebVTTContent | None
|
||||||
if webvtt_raw:
|
if webvtt_raw:
|
||||||
webvtt = WebVTTProcessor.parse(webvtt_raw)
|
webvtt = WebVTTProcessor.parse(webvtt_raw)
|
||||||
else:
|
else:
|
||||||
webvtt = None
|
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)
|
room_name: str | None = r_dict.pop("room_name", None)
|
||||||
db_result = SearchResultDB.model_validate(r_dict)
|
db_result = SearchResultDB.model_validate(r_dict)
|
||||||
|
|
||||||
snippets, total_match_count = SnippetGenerator.combine_sources(
|
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
|
long_summary, webvtt, params.query_text, DEFAULT_MAX_SNIPPETS
|
||||||
)
|
)
|
||||||
|
if has_query and at_least_one_source
|
||||||
|
else ([], 0)
|
||||||
|
)
|
||||||
|
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
**db_result.model_dump(),
|
**db_result.model_dump(),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from reflector.db.utils import is_postgresql
|
|||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
from reflector.processors.types import Word as ProcessorWord
|
from reflector.processors.types import Word as ProcessorWord
|
||||||
from reflector.settings import settings
|
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 import generate_uuid4
|
||||||
from reflector.utils.webvtt import topics_to_webvtt
|
from reflector.utils.webvtt import topics_to_webvtt
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ transcripts = sqlalchemy.Table(
|
|||||||
sqlalchemy.Column("title", sqlalchemy.String),
|
sqlalchemy.Column("title", sqlalchemy.String),
|
||||||
sqlalchemy.Column("short_summary", sqlalchemy.String),
|
sqlalchemy.Column("short_summary", sqlalchemy.String),
|
||||||
sqlalchemy.Column("long_summary", sqlalchemy.String),
|
sqlalchemy.Column("long_summary", sqlalchemy.String),
|
||||||
|
sqlalchemy.Column("action_items", sqlalchemy.JSON),
|
||||||
sqlalchemy.Column("topics", sqlalchemy.JSON),
|
sqlalchemy.Column("topics", sqlalchemy.JSON),
|
||||||
sqlalchemy.Column("events", sqlalchemy.JSON),
|
sqlalchemy.Column("events", sqlalchemy.JSON),
|
||||||
sqlalchemy.Column("participants", sqlalchemy.JSON),
|
sqlalchemy.Column("participants", sqlalchemy.JSON),
|
||||||
@@ -83,6 +84,8 @@ transcripts = sqlalchemy.Table(
|
|||||||
sqlalchemy.Column("audio_deleted", sqlalchemy.Boolean),
|
sqlalchemy.Column("audio_deleted", sqlalchemy.Boolean),
|
||||||
sqlalchemy.Column("room_id", sqlalchemy.String),
|
sqlalchemy.Column("room_id", sqlalchemy.String),
|
||||||
sqlalchemy.Column("webvtt", sqlalchemy.Text),
|
sqlalchemy.Column("webvtt", sqlalchemy.Text),
|
||||||
|
# Hatchet workflow run ID for resumption of failed workflows
|
||||||
|
sqlalchemy.Column("workflow_run_id", sqlalchemy.String),
|
||||||
sqlalchemy.Index("idx_transcript_recording_id", "recording_id"),
|
sqlalchemy.Index("idx_transcript_recording_id", "recording_id"),
|
||||||
sqlalchemy.Index("idx_transcript_user_id", "user_id"),
|
sqlalchemy.Index("idx_transcript_user_id", "user_id"),
|
||||||
sqlalchemy.Index("idx_transcript_created_at", "created_at"),
|
sqlalchemy.Index("idx_transcript_created_at", "created_at"),
|
||||||
@@ -122,6 +125,15 @@ def generate_transcript_name() -> str:
|
|||||||
return f"Transcript {now.strftime('%Y-%m-%d %H:%M:%S')}"
|
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):
|
class AudioWaveform(BaseModel):
|
||||||
data: list[float]
|
data: list[float]
|
||||||
|
|
||||||
@@ -155,6 +167,10 @@ class TranscriptFinalLongSummary(BaseModel):
|
|||||||
long_summary: str
|
long_summary: str
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptActionItems(BaseModel):
|
||||||
|
action_items: dict
|
||||||
|
|
||||||
|
|
||||||
class TranscriptFinalTitle(BaseModel):
|
class TranscriptFinalTitle(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
@@ -177,6 +193,7 @@ class TranscriptParticipant(BaseModel):
|
|||||||
id: str = Field(default_factory=generate_uuid4)
|
id: str = Field(default_factory=generate_uuid4)
|
||||||
speaker: int | None
|
speaker: int | None
|
||||||
name: str
|
name: str
|
||||||
|
user_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Transcript(BaseModel):
|
class Transcript(BaseModel):
|
||||||
@@ -185,7 +202,7 @@ class Transcript(BaseModel):
|
|||||||
id: str = Field(default_factory=generate_uuid4)
|
id: str = Field(default_factory=generate_uuid4)
|
||||||
user_id: str | None = None
|
user_id: str | None = None
|
||||||
name: str = Field(default_factory=generate_transcript_name)
|
name: str = Field(default_factory=generate_transcript_name)
|
||||||
status: str = "idle"
|
status: TranscriptStatus = "idle"
|
||||||
duration: float = 0
|
duration: float = 0
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
@@ -194,6 +211,7 @@ class Transcript(BaseModel):
|
|||||||
locked: bool = False
|
locked: bool = False
|
||||||
short_summary: str | None = None
|
short_summary: str | None = None
|
||||||
long_summary: str | None = None
|
long_summary: str | None = None
|
||||||
|
action_items: dict | None = None
|
||||||
topics: list[TranscriptTopic] = []
|
topics: list[TranscriptTopic] = []
|
||||||
events: list[TranscriptEvent] = []
|
events: list[TranscriptEvent] = []
|
||||||
participants: list[TranscriptParticipant] | None = []
|
participants: list[TranscriptParticipant] | None = []
|
||||||
@@ -207,6 +225,7 @@ class Transcript(BaseModel):
|
|||||||
zulip_message_id: int | None = None
|
zulip_message_id: int | None = None
|
||||||
audio_deleted: bool | None = None
|
audio_deleted: bool | None = None
|
||||||
webvtt: str | None = None
|
webvtt: str | None = None
|
||||||
|
workflow_run_id: str | None = None # Hatchet workflow run ID for resumption
|
||||||
|
|
||||||
@field_serializer("created_at", when_used="json")
|
@field_serializer("created_at", when_used="json")
|
||||||
def serialize_datetime(self, dt: datetime) -> str:
|
def serialize_datetime(self, dt: datetime) -> str:
|
||||||
@@ -358,7 +377,12 @@ class TranscriptController:
|
|||||||
room_id: str | None = None,
|
room_id: str | None = None,
|
||||||
search_term: str | None = None,
|
search_term: str | None = None,
|
||||||
return_query: bool = False,
|
return_query: bool = False,
|
||||||
exclude_columns: list[str] = ["topics", "events", "participants"],
|
exclude_columns: list[str] = [
|
||||||
|
"topics",
|
||||||
|
"events",
|
||||||
|
"participants",
|
||||||
|
"action_items",
|
||||||
|
],
|
||||||
) -> list[Transcript]:
|
) -> list[Transcript]:
|
||||||
"""
|
"""
|
||||||
Get all transcripts
|
Get all transcripts
|
||||||
@@ -614,7 +638,9 @@ class TranscriptController:
|
|||||||
)
|
)
|
||||||
if recording:
|
if recording:
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to delete recording object from S3",
|
"Failed to delete recording object from S3",
|
||||||
@@ -638,6 +664,19 @@ class TranscriptController:
|
|||||||
query = transcripts.delete().where(transcripts.c.recording_id == recording_id)
|
query = transcripts.delete().where(transcripts.c.recording_id == recording_id)
|
||||||
await get_database().execute(query)
|
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
|
@asynccontextmanager
|
||||||
async def transaction(self):
|
async def transaction(self):
|
||||||
"""
|
"""
|
||||||
@@ -703,11 +742,13 @@ class TranscriptController:
|
|||||||
"""
|
"""
|
||||||
Download audio from storage
|
Download audio from storage
|
||||||
"""
|
"""
|
||||||
transcript.audio_mp3_filename.write_bytes(
|
storage = get_transcripts_storage()
|
||||||
await get_transcripts_storage().get_file(
|
try:
|
||||||
transcript.storage_audio_path,
|
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(
|
async def upsert_participant(
|
||||||
self,
|
self,
|
||||||
@@ -732,5 +773,27 @@ class TranscriptController:
|
|||||||
transcript.delete_participant(participant_id)
|
transcript.delete_participant(participant_id)
|
||||||
await self.update(transcript, {"participants": transcript.participants_dump()})
|
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()
|
transcripts_controller = TranscriptController()
|
||||||
|
|||||||
91
server/reflector/db/user_api_keys.py
Normal file
91
server/reflector/db/user_api_keys.py
Normal file
@@ -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()
|
||||||
98
server/reflector/db/users.py
Normal file
98
server/reflector/db/users.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""User table for storing Authentik user information."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from reflector.db import get_database, metadata
|
||||||
|
from reflector.utils import generate_uuid4
|
||||||
|
from reflector.utils.string import NonEmptyString
|
||||||
|
|
||||||
|
users = sqlalchemy.Table(
|
||||||
|
"user",
|
||||||
|
metadata,
|
||||||
|
sqlalchemy.Column("id", sqlalchemy.String, primary_key=True),
|
||||||
|
sqlalchemy.Column("email", sqlalchemy.String, nullable=False),
|
||||||
|
sqlalchemy.Column("authentik_uid", sqlalchemy.String, nullable=False),
|
||||||
|
sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False),
|
||||||
|
sqlalchemy.Column("updated_at", sqlalchemy.DateTime(timezone=True), nullable=False),
|
||||||
|
sqlalchemy.Index("idx_user_authentik_uid", "authentik_uid", unique=True),
|
||||||
|
sqlalchemy.Index("idx_user_email", "email", unique=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseModel):
|
||||||
|
id: NonEmptyString = Field(default_factory=generate_uuid4)
|
||||||
|
email: NonEmptyString
|
||||||
|
authentik_uid: NonEmptyString
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class UserController:
|
||||||
|
@staticmethod
|
||||||
|
async def get_by_id(user_id: NonEmptyString) -> User | None:
|
||||||
|
query = users.select().where(users.c.id == user_id)
|
||||||
|
result = await get_database().fetch_one(query)
|
||||||
|
return User(**result) if result else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_by_authentik_uid(authentik_uid: NonEmptyString) -> User | None:
|
||||||
|
query = users.select().where(users.c.authentik_uid == authentik_uid)
|
||||||
|
result = await get_database().fetch_one(query)
|
||||||
|
return User(**result) if result else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_by_email(email: NonEmptyString) -> User | None:
|
||||||
|
query = users.select().where(users.c.email == email)
|
||||||
|
result = await get_database().fetch_one(query)
|
||||||
|
return User(**result) if result else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_or_update(
|
||||||
|
id: NonEmptyString, authentik_uid: NonEmptyString, email: NonEmptyString
|
||||||
|
) -> User:
|
||||||
|
existing = await UserController.get_by_authentik_uid(authentik_uid)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
query = (
|
||||||
|
users.update()
|
||||||
|
.where(users.c.authentik_uid == authentik_uid)
|
||||||
|
.values(email=email, updated_at=now)
|
||||||
|
)
|
||||||
|
await get_database().execute(query)
|
||||||
|
return User(
|
||||||
|
id=existing.id,
|
||||||
|
authentik_uid=authentik_uid,
|
||||||
|
email=email,
|
||||||
|
created_at=existing.created_at,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
user = User(
|
||||||
|
id=id,
|
||||||
|
authentik_uid=authentik_uid,
|
||||||
|
email=email,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
query = users.insert().values(**user.model_dump())
|
||||||
|
await get_database().execute(query)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def list_all() -> list[User]:
|
||||||
|
query = users.select().order_by(users.c.created_at.desc())
|
||||||
|
results = await get_database().fetch_all(query)
|
||||||
|
return [User(**r) for r in results]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_by_ids(user_ids: list[NonEmptyString]) -> dict[str, User]:
|
||||||
|
query = users.select().where(users.c.id.in_(user_ids))
|
||||||
|
results = await get_database().fetch_all(query)
|
||||||
|
return {user.id: User(**user) for user in results}
|
||||||
|
|
||||||
|
|
||||||
|
user_controller = UserController()
|
||||||
5
server/reflector/hatchet/__init__.py
Normal file
5
server/reflector/hatchet/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Hatchet workflow orchestration for Reflector."""
|
||||||
|
|
||||||
|
from reflector.hatchet.client import HatchetClientManager
|
||||||
|
|
||||||
|
__all__ = ["HatchetClientManager"]
|
||||||
98
server/reflector/hatchet/broadcast.py
Normal file
98
server/reflector/hatchet/broadcast.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""WebSocket broadcasting helpers for Hatchet workflows.
|
||||||
|
|
||||||
|
DUPLICATION NOTE: To be kept when Celery is deprecated. Currently dupes Celery logic.
|
||||||
|
|
||||||
|
Provides WebSocket broadcasting for Hatchet that matches Celery's @broadcast_to_sockets
|
||||||
|
decorator behavior. Events are broadcast to transcript rooms and user rooms.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from reflector.db.transcripts import Transcript, TranscriptEvent, transcripts_controller
|
||||||
|
from reflector.utils.string import NonEmptyString
|
||||||
|
from reflector.ws_manager import get_ws_manager
|
||||||
|
|
||||||
|
# Events that should also be sent to user room (matches Celery behavior)
|
||||||
|
USER_ROOM_EVENTS = {"STATUS", "FINAL_TITLE", "DURATION"}
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_event(
|
||||||
|
transcript_id: NonEmptyString,
|
||||||
|
event: TranscriptEvent,
|
||||||
|
logger: structlog.BoundLogger,
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast a TranscriptEvent to WebSocket subscribers.
|
||||||
|
|
||||||
|
Fire-and-forget: errors are logged but don't interrupt workflow execution.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Broadcasting event",
|
||||||
|
transcript_id=transcript_id,
|
||||||
|
event_type=event.event,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ws_manager = get_ws_manager()
|
||||||
|
|
||||||
|
await ws_manager.send_json(
|
||||||
|
room_id=f"ts:{transcript_id}",
|
||||||
|
message=event.model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Event sent to transcript room",
|
||||||
|
transcript_id=transcript_id,
|
||||||
|
event_type=event.event,
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.event in USER_ROOM_EVENTS:
|
||||||
|
transcript = await transcripts_controller.get_by_id(transcript_id)
|
||||||
|
if transcript and transcript.user_id:
|
||||||
|
await ws_manager.send_json(
|
||||||
|
room_id=f"user:{transcript.user_id}",
|
||||||
|
message={
|
||||||
|
"event": f"TRANSCRIPT_{event.event}",
|
||||||
|
"data": {"id": transcript_id, **event.data},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to broadcast event",
|
||||||
|
error=str(e),
|
||||||
|
transcript_id=transcript_id,
|
||||||
|
event_type=event.event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_status_and_broadcast(
|
||||||
|
transcript_id: NonEmptyString,
|
||||||
|
status: str,
|
||||||
|
logger: structlog.BoundLogger,
|
||||||
|
) -> None:
|
||||||
|
"""Set transcript status and broadcast to WebSocket.
|
||||||
|
|
||||||
|
Wrapper around transcripts_controller.set_status that adds WebSocket broadcasting.
|
||||||
|
"""
|
||||||
|
event = await transcripts_controller.set_status(transcript_id, status)
|
||||||
|
if event:
|
||||||
|
await broadcast_event(transcript_id, event, logger=logger)
|
||||||
|
|
||||||
|
|
||||||
|
async def append_event_and_broadcast(
|
||||||
|
transcript_id: NonEmptyString,
|
||||||
|
transcript: Transcript,
|
||||||
|
event_name: str,
|
||||||
|
data: Any,
|
||||||
|
logger: structlog.BoundLogger,
|
||||||
|
) -> TranscriptEvent:
|
||||||
|
"""Append event to transcript and broadcast to WebSocket.
|
||||||
|
|
||||||
|
Wrapper around transcripts_controller.append_event that adds WebSocket broadcasting.
|
||||||
|
"""
|
||||||
|
event = await transcripts_controller.append_event(
|
||||||
|
transcript=transcript,
|
||||||
|
event=event_name,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
await broadcast_event(transcript_id, event, logger=logger)
|
||||||
|
return event
|
||||||
115
server/reflector/hatchet/client.py
Normal file
115
server/reflector/hatchet/client.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""Hatchet Python client wrapper.
|
||||||
|
|
||||||
|
Uses singleton pattern because:
|
||||||
|
1. Hatchet client maintains persistent gRPC connections for workflow registration
|
||||||
|
2. Creating multiple clients would cause registration conflicts and resource leaks
|
||||||
|
3. The SDK is designed for a single client instance per process
|
||||||
|
4. Tests use `HatchetClientManager.reset()` to isolate state between tests
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from hatchet_sdk import ClientConfig, Hatchet
|
||||||
|
from hatchet_sdk.clients.rest.models import V1TaskStatus
|
||||||
|
|
||||||
|
from reflector.logger import logger
|
||||||
|
from reflector.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class HatchetClientManager:
|
||||||
|
"""Singleton manager for Hatchet client connections.
|
||||||
|
|
||||||
|
See module docstring for rationale. For test isolation, use `reset()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instance: Hatchet | None = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_client(cls) -> Hatchet:
|
||||||
|
"""Get or create the Hatchet client (thread-safe singleton)."""
|
||||||
|
if cls._instance is None:
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
if not settings.HATCHET_CLIENT_TOKEN:
|
||||||
|
raise ValueError("HATCHET_CLIENT_TOKEN must be set")
|
||||||
|
|
||||||
|
# Pass root logger to Hatchet so workflow logs appear in dashboard
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
cls._instance = Hatchet(
|
||||||
|
debug=settings.HATCHET_DEBUG,
|
||||||
|
config=ClientConfig(logger=root_logger),
|
||||||
|
)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def start_workflow(
|
||||||
|
cls,
|
||||||
|
workflow_name: str,
|
||||||
|
input_data: dict,
|
||||||
|
additional_metadata: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Start a workflow and return the workflow run ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
workflow_name: Name of the workflow to trigger.
|
||||||
|
input_data: Input data for the workflow run.
|
||||||
|
additional_metadata: Optional metadata for filtering in dashboard
|
||||||
|
(e.g., transcript_id, recording_id).
|
||||||
|
"""
|
||||||
|
client = cls.get_client()
|
||||||
|
result = await client.runs.aio_create(
|
||||||
|
workflow_name,
|
||||||
|
input_data,
|
||||||
|
additional_metadata=additional_metadata,
|
||||||
|
)
|
||||||
|
return result.run.metadata.id
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_workflow_run_status(cls, workflow_run_id: str) -> V1TaskStatus:
|
||||||
|
client = cls.get_client()
|
||||||
|
return await client.runs.aio_get_status(workflow_run_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def cancel_workflow(cls, workflow_run_id: str) -> None:
|
||||||
|
client = cls.get_client()
|
||||||
|
await client.runs.aio_cancel(workflow_run_id)
|
||||||
|
logger.info("[Hatchet] Cancelled workflow", workflow_run_id=workflow_run_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def replay_workflow(cls, workflow_run_id: str) -> None:
|
||||||
|
client = cls.get_client()
|
||||||
|
await client.runs.aio_replay(workflow_run_id)
|
||||||
|
logger.info("[Hatchet] Replaying workflow", workflow_run_id=workflow_run_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def can_replay(cls, workflow_run_id: str) -> bool:
|
||||||
|
"""Check if workflow can be replayed (is FAILED only).
|
||||||
|
|
||||||
|
CANCELLED workflows should start fresh (new run ID) rather than replay,
|
||||||
|
since cancellation indicates user intent to abort.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
status = await cls.get_workflow_run_status(workflow_run_id)
|
||||||
|
return status == V1TaskStatus.FAILED
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"[Hatchet] Failed to check replay status",
|
||||||
|
workflow_run_id=workflow_run_id,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_workflow_status(cls, workflow_run_id: str) -> dict:
|
||||||
|
"""Get the full workflow run details as dict."""
|
||||||
|
client = cls.get_client()
|
||||||
|
run = await client.runs.aio_get(workflow_run_id)
|
||||||
|
return run.to_dict()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset(cls) -> None:
|
||||||
|
"""Reset the client instance (for testing)."""
|
||||||
|
with cls._lock:
|
||||||
|
cls._instance = None
|
||||||
16
server/reflector/hatchet/constants.py
Normal file
16
server/reflector/hatchet/constants.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
Hatchet workflow constants.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Rate limit key for LLM API calls (shared across all LLM-calling tasks)
|
||||||
|
LLM_RATE_LIMIT_KEY = "llm"
|
||||||
|
|
||||||
|
# Max LLM calls per second across all tasks
|
||||||
|
LLM_RATE_LIMIT_PER_SECOND = 10
|
||||||
|
|
||||||
|
# Task execution timeouts (seconds)
|
||||||
|
TIMEOUT_SHORT = 60 # Quick operations: API calls, DB updates
|
||||||
|
TIMEOUT_MEDIUM = 120 # Single LLM calls, waveform generation
|
||||||
|
TIMEOUT_LONG = 180 # Action items (larger context LLM)
|
||||||
|
TIMEOUT_AUDIO = 300 # Audio processing: padding, mixdown
|
||||||
|
TIMEOUT_HEAVY = 600 # Transcription, fan-out LLM tasks
|
||||||
77
server/reflector/hatchet/run_workers.py
Normal file
77
server/reflector/hatchet/run_workers.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Run Hatchet workers for the diarization pipeline.
|
||||||
|
Runs as a separate process, just like Celery workers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run -m reflector.hatchet.run_workers
|
||||||
|
|
||||||
|
# Or via docker:
|
||||||
|
docker compose exec server uv run -m reflector.hatchet.run_workers
|
||||||
|
"""
|
||||||
|
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from hatchet_sdk.rate_limit import RateLimitDuration
|
||||||
|
|
||||||
|
from reflector.hatchet.constants import LLM_RATE_LIMIT_KEY, LLM_RATE_LIMIT_PER_SECOND
|
||||||
|
from reflector.logger import logger
|
||||||
|
from reflector.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Start Hatchet worker polling."""
|
||||||
|
if not settings.HATCHET_ENABLED:
|
||||||
|
logger.error("HATCHET_ENABLED is False, not starting workers")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not settings.HATCHET_CLIENT_TOKEN:
|
||||||
|
logger.error("HATCHET_CLIENT_TOKEN is not set")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Starting Hatchet workers",
|
||||||
|
debug=settings.HATCHET_DEBUG,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import here (not top-level) - workflow modules call HatchetClientManager.get_client()
|
||||||
|
# at module level because Hatchet SDK decorators (@workflow.task) bind at import time.
|
||||||
|
# Can't use lazy init: decorators need the client object when function is defined.
|
||||||
|
from reflector.hatchet.client import HatchetClientManager # noqa: PLC0415
|
||||||
|
from reflector.hatchet.workflows import ( # noqa: PLC0415
|
||||||
|
diarization_pipeline,
|
||||||
|
subject_workflow,
|
||||||
|
topic_chunk_workflow,
|
||||||
|
track_workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
hatchet = HatchetClientManager.get_client()
|
||||||
|
|
||||||
|
hatchet.rate_limits.put(
|
||||||
|
LLM_RATE_LIMIT_KEY, LLM_RATE_LIMIT_PER_SECOND, RateLimitDuration.SECOND
|
||||||
|
)
|
||||||
|
|
||||||
|
worker = hatchet.worker(
|
||||||
|
"reflector-pipeline-worker",
|
||||||
|
workflows=[
|
||||||
|
diarization_pipeline,
|
||||||
|
subject_workflow,
|
||||||
|
topic_chunk_workflow,
|
||||||
|
track_workflow,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def shutdown_handler(signum: int, frame) -> None:
|
||||||
|
logger.info("Received shutdown signal, stopping workers...")
|
||||||
|
# Worker cleanup happens automatically on exit
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, shutdown_handler)
|
||||||
|
signal.signal(signal.SIGTERM, shutdown_handler)
|
||||||
|
|
||||||
|
logger.info("Starting Hatchet worker polling...")
|
||||||
|
worker.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
26
server/reflector/hatchet/workflows/__init__.py
Normal file
26
server/reflector/hatchet/workflows/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Hatchet workflow definitions."""
|
||||||
|
|
||||||
|
from reflector.hatchet.workflows.diarization_pipeline import (
|
||||||
|
PipelineInput,
|
||||||
|
diarization_pipeline,
|
||||||
|
)
|
||||||
|
from reflector.hatchet.workflows.subject_processing import (
|
||||||
|
SubjectInput,
|
||||||
|
subject_workflow,
|
||||||
|
)
|
||||||
|
from reflector.hatchet.workflows.topic_chunk_processing import (
|
||||||
|
TopicChunkInput,
|
||||||
|
topic_chunk_workflow,
|
||||||
|
)
|
||||||
|
from reflector.hatchet.workflows.track_processing import TrackInput, track_workflow
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"diarization_pipeline",
|
||||||
|
"subject_workflow",
|
||||||
|
"topic_chunk_workflow",
|
||||||
|
"track_workflow",
|
||||||
|
"PipelineInput",
|
||||||
|
"SubjectInput",
|
||||||
|
"TopicChunkInput",
|
||||||
|
"TrackInput",
|
||||||
|
]
|
||||||
1263
server/reflector/hatchet/workflows/diarization_pipeline.py
Normal file
1263
server/reflector/hatchet/workflows/diarization_pipeline.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user