Compare commits

..

37 Commits

Author SHA1 Message Date
Juan Diego García
b570d202dc chore(main): release 0.43.0 (#940) 2026-03-31 19:27:00 -05:00
Juan Diego García
8c4f5e9c0f fix: cpu usage + email improvements (#944)
* fix: cpu usage on server ws manager, 100% to 0% on idle

* fix:  change email icon to white and prefill email in daily room for authenticated users

* fix: improve email sending with full ts transcript
2026-03-31 16:34:10 -05:00
Juan Diego García
ec8b49738e feat: show trash for soft deleted transcripts and hard delete option (#942)
* feat: show trash for soft deleted transcripts and hard delete option

* fix: test fixtures

* docs: aws new permissions
2026-03-31 13:15:52 -05:00
Juan Diego García
cc9c5cd4a5 fix: add parakeet as default transcriber and fix diarizer image (#939) 2026-03-31 10:22:57 -05:00
Juan Diego García
61d6fbd344 chore(main): release 0.42.0 (#935) 2026-03-30 18:48:27 -05:00
Juan Diego García
7b3b5b9858 fix: remove share public from integration tests (#938) 2026-03-30 18:02:56 -05:00
Juan Diego García
a22789d548 fix: grpc tls for local hatchet (#937) 2026-03-30 17:46:23 -05:00
dependabot[bot]
e3cc646cf5 build(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#934)
Bumps the npm_and_yarn group with 2 updates in the /docs directory: [brace-expansion](https://github.com/juliangruber/brace-expansion) and [path-to-regexp](https://github.com/pillarjs/path-to-regexp).


Updates `brace-expansion` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

Updates `path-to-regexp` from 0.1.12 to 0.1.13
- [Release notes](https://github.com/pillarjs/path-to-regexp/releases)
- [Changelog](https://github.com/pillarjs/path-to-regexp/blob/v.0.1.13/History.md)
- [Commits](https://github.com/pillarjs/path-to-regexp/compare/v0.1.12...v.0.1.13)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: path-to-regexp
  dependency-version: 0.1.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 17:38:52 -05:00
dependabot[bot]
778ff6268c build(deps): bump cryptography (#932)
Bumps the uv group with 1 update in the /server directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 46.0.5 to 46.0.6
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.5...46.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.6
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 17:38:37 -05:00
Juan Diego García
d164e486cc feat: mixdown modal services + processor pattern (#936)
* allow memory flags and per service config

* feat: mixdown modal services + processor pattern
2026-03-30 17:38:23 -05:00
Juan Diego García
12bf0c2d77 feat: custom ca for caddy (#931)
* fix: send email on transcript page permissions fixed

* feat: custom ca for caddy
2026-03-30 11:42:39 -05:00
dependabot[bot]
bfaf4f403b build(deps): bump the uv group across 2 directories with 1 update (#930)
Bumps the uv group with 1 update in the /gpu/self_hosted directory: [requests](https://github.com/psf/requests).
Bumps the uv group with 1 update in the /server directory: [requests](https://github.com/psf/requests).


Updates `requests` from 2.32.5 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

Updates `requests` from 2.32.4 to 2.33.0
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.5...v2.33.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: indirect
  dependency-group: uv
- dependency-name: requests
  dependency-version: 2.33.0
  dependency-type: direct:production
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 10:27:11 -05:00
dependabot[bot]
0258754a4c build(deps): bump picomatch (#929)
Bumps the npm_and_yarn group with 1 update in the /docs directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 10:26:16 -05:00
Juan Diego García
ea89fa5261 chore(main): release 0.41.0 (#925) 2026-03-25 17:33:44 -05:00
Juan Diego García
1f98790e7b feat: zulip dag monitor for failed runs (#928)
* feat: zulip dag monitor for failed runs

* fix: add collapsible tags to big information
2026-03-25 17:26:41 -05:00
dependabot[bot]
7b8d190c52 build(deps): bump the uv group across 1 directory with 2 updates (#927)
Bumps the uv group with 2 updates in the /server directory: [nltk](https://github.com/nltk/nltk) and [pypdf](https://github.com/py-pdf/pypdf).


Updates `nltk` from 3.9.3 to 3.9.4
- [Changelog](https://github.com/nltk/nltk/blob/develop/ChangeLog)
- [Commits](https://github.com/nltk/nltk/compare/3.9.3...3.9.4)

Updates `pypdf` from 6.9.1 to 6.9.2
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.9.1...6.9.2)

---
updated-dependencies:
- dependency-name: nltk
  dependency-version: 3.9.4
  dependency-type: indirect
  dependency-group: uv
- dependency-name: pypdf
  dependency-version: 6.9.2
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-25 17:13:43 -05:00
Juan Diego García
f19113a3cf feat: add auto-generated captions, speaker-colored progress bar with sync controls, and speaker tooltip to cloud video player (#926)
* feat: webvtt captions inside video with sync controls

* feat: highlight speaker timestamp progress bar
2026-03-25 12:07:08 -05:00
Juan Diego García
e2ba502697 feat: send email in share transcript and add email sending in room (#924)
* fix: add source language for file pipeline

* feat: send email in share transcript and add email sending in room

* fix: hide audio and video streaming for unauthenticated users

* fix: security order
2026-03-24 17:17:52 -05:00
Juan Diego García
74b9b97453 chore(main): release 0.40.0 (#921) 2026-03-20 15:57:59 -05:00
dependabot[bot]
9e37d60b3f build(deps): bump flatted (#922)
Bumps the npm_and_yarn group with 1 update in the /www directory: [flatted](https://github.com/WebReflection/flatted).


Updates `flatted` from 3.4.1 to 3.4.2
- [Commits](https://github.com/WebReflection/flatted/compare/v3.4.1...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-20 15:44:14 -05:00
Juan Diego García
55222ecc47 feat: allow participants to ask for email transcript (#923)
* feat: allow participants to ask for email transcript

* fix: set email update in a transaction
2026-03-20 15:43:58 -05:00
dependabot[bot]
41e7b3e84f build(deps): bump socket.io-parser (#918)
Bumps the npm_and_yarn group with 1 update in the /www directory: [socket.io-parser](https://github.com/socketio/socket.io).


Updates `socket.io-parser` from 4.2.5 to 4.2.6
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.5...socket.io-parser@4.2.6)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.6
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-20 11:33:14 -05:00
dependabot[bot]
e5712a4168 build(deps): bump pypdf in /server in the uv group across 1 directory (#917)
Bumps the uv group with 1 update in the /server directory: [pypdf](https://github.com/py-pdf/pypdf).


Updates `pypdf` from 6.8.0 to 6.9.1
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.8.0...6.9.1)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.9.1
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-20 11:33:00 -05:00
Juan Diego García
a76f114378 feat: download files, show cloud video, solf deletion with no reprocessing (#920)
* fix: move upd ports out of MacOS internal Range

* feat: download files, show cloud video, solf deletion with no reprocessing
2026-03-20 11:04:53 -05:00
Juan Diego García
cb1beae90d chore(main): release 0.39.0 (#913) 2026-03-18 19:01:43 -05:00
Juan Diego García
1e396ca0ca fix: integration tests runner in CI (#919) 2026-03-18 15:51:17 -05:00
Juan Diego García
9a2f973a2e test: full integration tests (#916)
* test: full integration tests

* fix: add env vars as secrets in CI
2026-03-18 15:29:21 -05:00
Juan Diego García
a9200d35bf fix: latest vulns (#915) 2026-03-17 12:04:48 -05:00
dependabot[bot]
5646319e96 build(deps): bump pyopenssl (#914)
Bumps the uv group with 1 update in the /server directory: [pyopenssl](https://github.com/pyca/pyopenssl).


Updates `pyopenssl` from 25.3.0 to 26.0.0
- [Changelog](https://github.com/pyca/pyopenssl/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/pyopenssl/compare/25.3.0...26.0.0)

---
updated-dependencies:
- dependency-name: pyopenssl
  dependency-version: 26.0.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 11:18:46 -05:00
dependabot[bot]
d0472ebf5f build(deps): bump flatted (#912)
Bumps the npm_and_yarn group with 1 update in the /www directory: [flatted](https://github.com/WebReflection/flatted).


Updates `flatted` from 3.3.3 to 3.4.1
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.1)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 11:18:32 -05:00
dependabot[bot]
628a6d735c build(deps-dev): bump black (#910)
Bumps the uv group with 1 update in the /server directory: [black](https://github.com/psf/black).


Updates `black` from 24.3.0 to 26.3.1
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.3.0...26.3.1)

---
updated-dependencies:
- dependency-name: black
  dependency-version: 26.3.1
  dependency-type: direct:development
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 10:48:23 -05:00
Juan Diego García
37a1f01850 feat: migrate file and live post-processing pipelines from Celery to Hatchet workflow engine (#911)
* feat: migrate file and live post-processing pipelines from Celery to Hatchet workflow engine

* fix: always force reprocessing

* fix: ci tests with live pipelines

* fix: ci tests with live pipelines
2026-03-16 16:07:16 -05:00
Juan Diego García
72dca7cacc chore(main): release 0.38.2 (#906) 2026-03-12 16:51:53 -05:00
Juan Diego García
4ae56b730a refactor(auth): consolidate PUBLIC_MODE and mutation guards into reusable helpers (#909)
* refactor(auth): consolidate PUBLIC_MODE and mutation guards into reusable helpers

* fix: fix websocket test override
2026-03-12 10:51:26 -05:00
Juan Diego García
cf6e867cf1 fix: add auth guards to prevent anonymous access to write endpoints in non-public mode (#907)
* fix: add auth guards to prevent anonymous access to write endpoints in non-public mode

* test: anon data accessible regardless of guards

* fix: celery test
2026-03-11 10:48:49 -05:00
dependabot[bot]
183601a121 build(deps): bump pypdf in /server in the uv group across 1 directory (#908)
Bumps the uv group with 1 update in the /server directory: [pypdf](https://github.com/py-pdf/pypdf).


Updates `pypdf` from 6.7.5 to 6.8.0
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.7.5...6.8.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.8.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 10:29:43 -05:00
Sergey Mankovsky
b53c8da398 fix: add tests that check some of the issues are already fixed (#905)
* Add tests that check some of the issues are already fixed

* Fix test formatting
2026-03-10 11:58:53 -05:00
150 changed files with 17832 additions and 8952 deletions

139
.github/workflows/integration_tests.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
name: Integration Tests
on:
workflow_dispatch:
inputs:
llm_model:
description: "LLM model name (overrides LLM_MODEL secret)"
required: false
default: ""
type: string
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Start infrastructure services
working-directory: server/tests
env:
LLM_URL: ${{ secrets.LLM_URL }}
LLM_MODEL: ${{ inputs.llm_model || secrets.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
docker compose -f docker-compose.integration.yml up -d --build postgres redis garage hatchet mock-daily
- name: Set up Garage bucket and keys
working-directory: server/tests
run: |
GARAGE="docker compose -f docker-compose.integration.yml exec -T garage /garage"
GARAGE_KEY_ID="GK0123456789abcdef01234567" # gitleaks:allow
GARAGE_KEY_SECRET="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # gitleaks:allow
echo "Waiting for Garage to be healthy..."
for i in $(seq 1 60); do
if $GARAGE stats &>/dev/null; then break; fi
sleep 2
done
echo "Setting up Garage..."
NODE_ID=$($GARAGE node id -q 2>&1 | tr -d '[:space:]')
LAYOUT_STATUS=$($GARAGE layout show 2>&1 || true)
if echo "$LAYOUT_STATUS" | grep -q "No nodes"; then
$GARAGE layout assign "$NODE_ID" -c 1G -z dc1
$GARAGE layout apply --version 1
fi
$GARAGE bucket info reflector-media &>/dev/null || $GARAGE bucket create reflector-media
if ! $GARAGE key info reflector-test &>/dev/null; then
$GARAGE key import --yes "$GARAGE_KEY_ID" "$GARAGE_KEY_SECRET"
$GARAGE key rename "$GARAGE_KEY_ID" reflector-test
fi
$GARAGE bucket allow reflector-media --read --write --key reflector-test
- name: Wait for Hatchet and generate API token
working-directory: server/tests
run: |
echo "Waiting for Hatchet to be healthy..."
for i in $(seq 1 90); do
if docker compose -f docker-compose.integration.yml exec -T hatchet curl -sf http://localhost:8888/api/live &>/dev/null; then
echo "Hatchet is ready."
break
fi
sleep 2
done
echo "Generating Hatchet API token..."
HATCHET_OUTPUT=$(docker compose -f docker-compose.integration.yml exec -T hatchet \
/hatchet-admin token create --config /config --name integration-test 2>&1)
HATCHET_TOKEN=$(echo "$HATCHET_OUTPUT" | grep -o 'eyJ[A-Za-z0-9_.\-]*')
if [ -z "$HATCHET_TOKEN" ]; then
echo "ERROR: Failed to extract Hatchet JWT token"
exit 1
fi
echo "HATCHET_CLIENT_TOKEN=${HATCHET_TOKEN}" >> $GITHUB_ENV
- name: Start backend services
working-directory: server/tests
env:
LLM_URL: ${{ secrets.LLM_URL }}
LLM_MODEL: ${{ inputs.llm_model || secrets.LLM_MODEL }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
# Export garage and hatchet credentials for backend services
export GARAGE_KEY_ID="${{ env.GARAGE_KEY_ID }}"
export GARAGE_KEY_SECRET="${{ env.GARAGE_KEY_SECRET }}"
export HATCHET_CLIENT_TOKEN="${{ env.HATCHET_CLIENT_TOKEN }}"
docker compose -f docker-compose.integration.yml up -d \
server worker hatchet-worker-cpu hatchet-worker-llm test-runner
- name: Wait for server health check
working-directory: server/tests
run: |
echo "Waiting for server to be healthy..."
for i in $(seq 1 60); do
if docker compose -f docker-compose.integration.yml exec -T test-runner \
curl -sf http://server:1250/health &>/dev/null; then
echo "Server is ready."
break
fi
sleep 3
done
- name: Run DB migrations
working-directory: server/tests
run: |
docker compose -f docker-compose.integration.yml exec -T server \
uv run alembic upgrade head
- name: Run integration tests
working-directory: server/tests
run: |
docker compose -f docker-compose.integration.yml exec -T test-runner \
uv run pytest tests/integration/ -v -x
- name: Collect logs on failure
if: failure()
working-directory: server/tests
run: |
docker compose -f docker-compose.integration.yml logs --tail=500 > integration-logs.txt 2>&1
- name: Upload logs artifact
if: failure()
uses: actions/upload-artifact@v4
with:
name: integration-logs
path: server/tests/integration-logs.txt
retention-days: 7
- name: Teardown
if: always()
working-directory: server/tests
run: |
docker compose -f docker-compose.integration.yml down -v --remove-orphans

6
.gitignore vendored
View File

@@ -24,4 +24,10 @@ www/.env.production
.secrets
opencode.json
certs/
docker-compose.ca.yml
docker-compose.gpu-ca.yml
Caddyfile.gpu-host
.env.gpu-host
vibedocs/
server/tests/integration/logs/

View File

@@ -1,5 +1,6 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
exclude: '(^uv\.lock$|pnpm-lock\.yaml$)'
repos:
- repo: local
hooks:

View File

@@ -1,5 +1,70 @@
# Changelog
## [0.43.0](https://github.com/GreyhavenHQ/reflector/compare/v0.42.0...v0.43.0) (2026-03-31)
### Features
* show trash for soft deleted transcripts and hard delete option ([#942](https://github.com/GreyhavenHQ/reflector/issues/942)) ([ec8b497](https://github.com/GreyhavenHQ/reflector/commit/ec8b49738e8e76f6e5d2496a42cb454ef6c2d7c7))
### Bug Fixes
* add parakeet as default transcriber and fix diarizer image ([#939](https://github.com/GreyhavenHQ/reflector/issues/939)) ([cc9c5cd](https://github.com/GreyhavenHQ/reflector/commit/cc9c5cd4a5f4123ef957ad82461ca37a727d1ba6))
* cpu usage + email improvements ([#944](https://github.com/GreyhavenHQ/reflector/issues/944)) ([8c4f5e9](https://github.com/GreyhavenHQ/reflector/commit/8c4f5e9c0f893f4cb029595505b53136f04760f4))
## [0.42.0](https://github.com/GreyhavenHQ/reflector/compare/v0.41.0...v0.42.0) (2026-03-30)
### Features
* custom ca for caddy ([#931](https://github.com/GreyhavenHQ/reflector/issues/931)) ([12bf0c2](https://github.com/GreyhavenHQ/reflector/commit/12bf0c2d77f9915b79b1eb1decd77ed2dadbb31d))
* mixdown modal services + processor pattern ([#936](https://github.com/GreyhavenHQ/reflector/issues/936)) ([d164e48](https://github.com/GreyhavenHQ/reflector/commit/d164e486cc33ff8babf6cff6c163893cfc56fd76))
### Bug Fixes
* grpc tls for local hatchet ([#937](https://github.com/GreyhavenHQ/reflector/issues/937)) ([a22789d](https://github.com/GreyhavenHQ/reflector/commit/a22789d5486bf8b83e33ab2fb5eb3ee9799c6d47))
* remove share public from integration tests ([#938](https://github.com/GreyhavenHQ/reflector/issues/938)) ([7b3b5b9](https://github.com/GreyhavenHQ/reflector/commit/7b3b5b98586449afd0b6996ba9fd7aec8308bbc6))
## [0.41.0](https://github.com/GreyhavenHQ/reflector/compare/v0.40.0...v0.41.0) (2026-03-25)
### Features
* add auto-generated captions, speaker-colored progress bar with sync controls, and speaker tooltip to cloud video player ([#926](https://github.com/GreyhavenHQ/reflector/issues/926)) ([f19113a](https://github.com/GreyhavenHQ/reflector/commit/f19113a3cfa27797a70b9496bfcf1baff9d89f0d))
* send email in share transcript and add email sending in room ([#924](https://github.com/GreyhavenHQ/reflector/issues/924)) ([e2ba502](https://github.com/GreyhavenHQ/reflector/commit/e2ba502697ce331c4d87fb019648fcbe4e7cca73))
* zulip dag monitor for failed runs ([#928](https://github.com/GreyhavenHQ/reflector/issues/928)) ([1f98790](https://github.com/GreyhavenHQ/reflector/commit/1f98790e7bc58013690ec81aefa051da5e36e93e))
## [0.40.0](https://github.com/GreyhavenHQ/reflector/compare/v0.39.0...v0.40.0) (2026-03-20)
### Features
* allow participants to ask for email transcript ([#923](https://github.com/GreyhavenHQ/reflector/issues/923)) ([55222ec](https://github.com/GreyhavenHQ/reflector/commit/55222ecc4736f99ad461f03a006c8d97b5876142))
* download files, show cloud video, solf deletion with no reprocessing ([#920](https://github.com/GreyhavenHQ/reflector/issues/920)) ([a76f114](https://github.com/GreyhavenHQ/reflector/commit/a76f1143783d3cf137a8847a851b72302e04445b))
## [0.39.0](https://github.com/GreyhavenHQ/reflector/compare/v0.38.2...v0.39.0) (2026-03-18)
### Features
* migrate file and live post-processing pipelines from Celery to Hatchet workflow engine ([#911](https://github.com/GreyhavenHQ/reflector/issues/911)) ([37a1f01](https://github.com/GreyhavenHQ/reflector/commit/37a1f0185057dd43b68df2b12bb08d3b18e28d34))
### Bug Fixes
* integration tests runner in CI ([#919](https://github.com/GreyhavenHQ/reflector/issues/919)) ([1e396ca](https://github.com/GreyhavenHQ/reflector/commit/1e396ca0ca91bc9d2645ddfc63a1576469491faa))
* latest vulns ([#915](https://github.com/GreyhavenHQ/reflector/issues/915)) ([a9200d3](https://github.com/GreyhavenHQ/reflector/commit/a9200d35bf856f65f24a4f34931ebe0d75ad0382))
## [0.38.2](https://github.com/GreyhavenHQ/reflector/compare/v0.38.1...v0.38.2) (2026-03-12)
### Bug Fixes
* add auth guards to prevent anonymous access to write endpoints in non-public mode ([#907](https://github.com/GreyhavenHQ/reflector/issues/907)) ([cf6e867](https://github.com/GreyhavenHQ/reflector/commit/cf6e867cf12c42411e5a7412f6ec44eee8351665))
* add tests that check some of the issues are already fixed ([#905](https://github.com/GreyhavenHQ/reflector/issues/905)) ([b53c8da](https://github.com/GreyhavenHQ/reflector/commit/b53c8da3981c394bdab08504b45d25f62c35495a))
## [0.38.1](https://github.com/GreyhavenHQ/reflector/compare/v0.38.0...v0.38.1) (2026-03-06)

View File

@@ -41,14 +41,14 @@ uv run celery -A reflector.worker.app beat
**Testing:**
```bash
# Run all tests with coverage
uv run pytest
# Run all tests with coverage (requires Redis on localhost)
REDIS_HOST=localhost REDIS_PORT=6379 uv run pytest
# Run specific test file
uv run pytest tests/test_transcripts.py
REDIS_HOST=localhost REDIS_PORT=6379 uv run pytest tests/test_transcripts.py
# Run tests with verbose output
uv run pytest -v
REDIS_HOST=localhost REDIS_PORT=6379 uv run pytest -v
```
**Process Audio Files:**
@@ -160,6 +160,21 @@ All endpoints prefixed `/v1/`:
- **Frontend**: No current test suite - opportunities for Jest/React Testing Library
- **Coverage**: Backend maintains test coverage reports in `htmlcov/`
### Integration Tests (DO NOT run unless explicitly asked)
There are end-to-end integration tests in `server/tests/integration/` that spin up the full stack (PostgreSQL, Redis, Hatchet, Garage, mock-daily, server, workers) via Docker Compose and exercise real processing pipelines. These tests are:
- `test_file_pipeline.py` — File upload → FilePipeline
- `test_live_pipeline.py` — WebRTC stream → LivePostPipeline
- `test_multitrack_pipeline.py` — Multitrack → DailyMultitrackPipeline
**Important:**
- These tests are **excluded** from normal `uv run pytest` runs via `--ignore=tests/integration` in pyproject.toml.
- Do **NOT** run them as part of verification, code review, or general testing unless the user explicitly asks.
- They require Docker, external LLM credentials, and HuggingFace token — they cannot run in a regular test environment.
- To run locally: `./scripts/run-integration-tests.sh` (requires env vars: `LLM_URL`, `LLM_API_KEY`, `HF_TOKEN`).
- In CI: triggered manually via the "Integration Tests" GitHub Actions workflow (`workflow_dispatch`).
## GPU Processing
Modal.com integration for scalable ML processing:
@@ -177,3 +192,13 @@ Modal.com integration for scalable ML processing:
## Pipeline/worker related info
If you need to do any worker/pipeline related work, search for "Pipeline" classes and their "create" or "build" methods to find the main processor sequence. Look for task orchestration patterns (like "chord", "group", or "chain") to identify the post-processing flow with parallel execution chains. This will give you abstract vision on how processing pipeling is organized.
## Documentation
- New documentation files go in `docsv2/`, not in `docs/docs/`.
- Existing `docs/` directory contains legacy Docusaurus docs.
## Code Style
- Always put imports at the top of the file. Let ruff/pre-commit handle sorting and formatting of imports.
- Exception: In Hatchet pipeline task functions, DB controller imports (e.g., `transcripts_controller`, `meetings_controller`) stay as deferred/inline imports inside `fresh_db_connection()` blocks — this is intentional to avoid sharing DB connections across forked processes. Non-DB imports (utilities, services) should still go at the top of the file.

106
docker-compose.gpu-host.yml Normal file
View File

@@ -0,0 +1,106 @@
# Standalone GPU host for Reflector — transcription, diarization, translation.
#
# Usage: ./scripts/setup-gpu-host.sh [--domain DOMAIN] [--custom-ca PATH] [--api-key KEY] [--cpu]
# or: docker compose -f docker-compose.gpu-host.yml --profile gpu [--profile caddy] up -d
#
# Processing mode (pick ONE — mutually exclusive, both bind port 8000):
# --profile gpu NVIDIA GPU container (requires nvidia-container-toolkit)
# --profile cpu CPU-only container (no GPU required, slower)
#
# Optional:
# --profile caddy Caddy reverse proxy with HTTPS
#
# This file is checked into the repo. The setup script generates:
# - .env.gpu-host (HF_TOKEN, API key, port config)
# - Caddyfile.gpu-host (Caddy config, only with --domain)
# - docker-compose.gpu-ca.yml (CA cert mounts, only with --custom-ca)
services:
# ===========================================================
# GPU service — NVIDIA GPU accelerated
# Activated with: --profile gpu
# ===========================================================
gpu:
build:
context: ./gpu/self_hosted
dockerfile: Dockerfile
profiles: [gpu]
restart: unless-stopped
ports:
- "${GPU_HOST_PORT:-8000}:8000"
environment:
HF_TOKEN: ${HF_TOKEN:-}
REFLECTOR_GPU_APIKEY: ${REFLECTOR_GPU_APIKEY:-}
volumes:
- gpu_cache:/root/.cache
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
interval: 15s
timeout: 5s
retries: 10
start_period: 120s
networks:
default:
aliases:
- transcription
# ===========================================================
# CPU service — no GPU required, uses Dockerfile.cpu
# Activated with: --profile cpu
# Mutually exclusive with gpu (both bind port 8000)
# ===========================================================
cpu:
build:
context: ./gpu/self_hosted
dockerfile: Dockerfile.cpu
profiles: [cpu]
restart: unless-stopped
ports:
- "${GPU_HOST_PORT:-8000}:8000"
environment:
HF_TOKEN: ${HF_TOKEN:-}
REFLECTOR_GPU_APIKEY: ${REFLECTOR_GPU_APIKEY:-}
volumes:
- gpu_cache:/root/.cache
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
interval: 15s
timeout: 5s
retries: 10
start_period: 120s
networks:
default:
aliases:
- transcription
# ===========================================================
# Caddy — reverse proxy with HTTPS (optional)
# Activated with: --profile caddy
# Proxies to "transcription" network alias (works for both gpu and cpu)
# ===========================================================
caddy:
image: caddy:2-alpine
profiles: [caddy]
restart: unless-stopped
ports:
- "80:80"
- "${CADDY_HTTPS_PORT:-443}:443"
volumes:
- ./Caddyfile.gpu-host:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
gpu_cache:
caddy_data:
caddy_config:

View File

@@ -36,7 +36,7 @@ services:
restart: unless-stopped
ports:
- "127.0.0.1:1250:1250"
- "51000-51100:51000-51100/udp"
- "40000-40100:40000-40100/udp"
env_file:
- ./server/.env
environment:
@@ -50,7 +50,10 @@ services:
# HF_TOKEN needed for in-process pyannote diarization (--cpu mode)
HF_TOKEN: ${HF_TOKEN:-}
# WebRTC: fixed UDP port range for ICE candidates (mapped above)
WEBRTC_PORT_RANGE: "51000-51100"
WEBRTC_PORT_RANGE: "40000-40100"
# Hatchet workflow engine (always-on for processing pipelines)
HATCHET_CLIENT_SERVER_URL: ${HATCHET_CLIENT_SERVER_URL:-http://hatchet:8888}
HATCHET_CLIENT_HOST_PORT: ${HATCHET_CLIENT_HOST_PORT:-hatchet:7077}
depends_on:
postgres:
condition: service_healthy
@@ -75,6 +78,9 @@ services:
CELERY_RESULT_BACKEND: redis://redis:6379/1
# ML backend config comes from env_file (server/.env), set per-mode by setup script
HF_TOKEN: ${HF_TOKEN:-}
# Hatchet workflow engine (always-on for processing pipelines)
HATCHET_CLIENT_SERVER_URL: ${HATCHET_CLIENT_SERVER_URL:-http://hatchet:8888}
HATCHET_CLIENT_HOST_PORT: ${HATCHET_CLIENT_HOST_PORT:-hatchet:7077}
depends_on:
postgres:
condition: service_healthy
@@ -126,6 +132,8 @@ services:
redis:
image: redis:7.2-alpine
restart: unless-stopped
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
@@ -301,20 +309,38 @@ services:
- server
# ===========================================================
# Hatchet + Daily.co workers (optional — for Daily.co multitrack processing)
# Auto-enabled when DAILY_API_KEY is configured in server/r
# Mailpit — local SMTP sink for testing email transcript notifications
# Start with: --profile mailpit
# Web UI at http://localhost:8025
# ===========================================================
mailpit:
image: axllent/mailpit:latest
profiles: [mailpit]
restart: unless-stopped
ports:
- "127.0.0.1:8025:8025" # Web UI
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025/api/v1/messages"]
interval: 10s
timeout: 3s
retries: 5
# ===========================================================
# Hatchet workflow engine + workers
# Required for all processing pipelines (file, live, Daily.co multitrack).
# Always-on — every selfhosted deployment needs Hatchet.
# ===========================================================
hatchet:
image: ghcr.io/hatchet-dev/hatchet/hatchet-lite:latest
profiles: [dailyco]
restart: on-failure
depends_on:
postgres:
condition: service_healthy
ports:
- "8888:8888"
- "7078:7077"
- "127.0.0.1:8888:8888"
- "127.0.0.1:7078:7077"
env_file:
- ./.env.hatchet
environment:
@@ -363,7 +389,6 @@ services:
context: ./server
dockerfile: Dockerfile
image: monadicalsas/reflector-backend:latest
profiles: [dailyco]
restart: unless-stopped
env_file:
- ./server/.env

View File

@@ -95,6 +95,12 @@ DAILYCO_STORAGE_AWS_BUCKET_NAME=<your-bucket-from-daily-setup>
DAILYCO_STORAGE_AWS_REGION=us-east-1
DAILYCO_STORAGE_AWS_ROLE_ARN=<your-role-arn-from-daily-setup>
# Worker credentials for reading/deleting recordings from Daily's S3 bucket.
# Required when transcript storage uses a different bucket or credentials
# (e.g., selfhosted with Garage or a separate S3 account).
DAILYCO_STORAGE_AWS_ACCESS_KEY_ID=<your-aws-access-key>
DAILYCO_STORAGE_AWS_SECRET_ACCESS_KEY=<your-aws-secret-key>
# Transcript storage (should already be configured from main setup)
# TRANSCRIPT_STORAGE_BACKEND=aws
# TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID=<your-key>
@@ -103,6 +109,19 @@ DAILYCO_STORAGE_AWS_ROLE_ARN=<your-role-arn-from-daily-setup>
# TRANSCRIPT_STORAGE_AWS_REGION=<your-bucket-region>
```
:::info Two separate credential sets for Daily.co
- **`ROLE_ARN`** — Used by Daily's API to *write* recordings into your S3 bucket (configured via Daily dashboard).
- **`ACCESS_KEY_ID` / `SECRET_ACCESS_KEY`** — Used by Reflector workers to *read* recordings for transcription and *delete* them on consent denial or permanent transcript deletion.
Required IAM permissions for the worker key on the Daily recordings bucket:
- `s3:GetObject` — Download recording files for processing
- `s3:DeleteObject` — Remove files on consent denial, trash destroy, or data retention cleanup
- `s3:ListBucket` — Scan for recordings needing reprocessing
If the worker keys are not set, Reflector falls back to the transcript storage master key, which then needs cross-bucket access to the Daily bucket.
:::
---
## Restart Services

76
docs/pnpm-lock.yaml generated
View File

@@ -701,6 +701,10 @@ packages:
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -1490,42 +1494,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -2254,11 +2252,11 @@ packages:
resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==}
engines: {node: '>=14.16'}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@1.1.13:
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@2.0.3:
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -3410,8 +3408,8 @@ packages:
graphlib@2.1.8:
resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
gray-matter@https://codeload.github.com/jonschlinkert/gray-matter/tar.gz/234163e317c87fe031e9368ffabde9c9149ce3ec:
resolution: {tarball: https://codeload.github.com/jonschlinkert/gray-matter/tar.gz/234163e317c87fe031e9368ffabde9c9149ce3ec}
gray-matter@https://codeload.github.com/jonschlinkert/gray-matter/tar.gz/234163e:
resolution: {tarball: https://codeload.github.com/jonschlinkert/gray-matter/tar.gz/234163e}
version: 4.0.3
engines: {node: '>=6.0'}
@@ -4533,8 +4531,8 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-to-regexp@0.1.12:
resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
path-to-regexp@0.1.13:
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
path-to-regexp@1.9.0:
resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==}
@@ -4555,12 +4553,12 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pirates@4.0.7:
@@ -7024,6 +7022,8 @@ snapshots:
'@babel/runtime@7.28.6': {}
'@babel/runtime@7.29.2': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -8162,7 +8162,7 @@ snapshots:
fs-extra: 11.3.3
github-slugger: 1.5.0
globby: 11.1.0
gray-matter: https://codeload.github.com/jonschlinkert/gray-matter/tar.gz/234163e317c87fe031e9368ffabde9c9149ce3ec
gray-matter: https://codeload.github.com/jonschlinkert/gray-matter/tar.gz/234163e
jiti: 1.21.7
js-yaml: 4.1.1
lodash: 4.17.23
@@ -8473,7 +8473,7 @@ snapshots:
detect-libc: 2.1.2
is-glob: 4.0.3
node-addon-api: 7.1.1
picomatch: 4.0.3
picomatch: 4.0.4
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6
@@ -8645,7 +8645,7 @@ snapshots:
'@slorber/react-helmet-async@1.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.29.2
invariant: 2.2.4
prop-types: 15.8.1
react: 19.2.4
@@ -9244,7 +9244,7 @@ snapshots:
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
picomatch: 2.3.2
arg@5.0.2: {}
@@ -9378,12 +9378,12 @@ snapshots:
widest-line: 4.0.1
wrap-ansi: 8.1.0
brace-expansion@1.1.12:
brace-expansion@1.1.13:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.2:
brace-expansion@2.0.3:
dependencies:
balanced-match: 1.0.2
@@ -10436,7 +10436,7 @@ snapshots:
methods: 1.1.2
on-finished: 2.4.1
parseurl: 1.3.3
path-to-regexp: 0.1.12
path-to-regexp: 0.1.13
proxy-addr: 2.0.7
qs: 6.14.2
range-parser: 1.2.1
@@ -10485,9 +10485,9 @@ snapshots:
dependencies:
websocket-driver: 0.7.4
fdir@6.5.0(picomatch@4.0.3):
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.3
picomatch: 4.0.4
feed@4.2.2:
dependencies:
@@ -10658,7 +10658,7 @@ snapshots:
dependencies:
lodash: 4.17.23
gray-matter@https://codeload.github.com/jonschlinkert/gray-matter/tar.gz/234163e317c87fe031e9368ffabde9c9149ce3ec:
gray-matter@https://codeload.github.com/jonschlinkert/gray-matter/tar.gz/234163e:
dependencies:
js-yaml: 4.1.1
kind-of: 6.0.3
@@ -11080,7 +11080,7 @@ snapshots:
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
picomatch: 2.3.1
picomatch: 2.3.2
jest-worker@27.5.1:
dependencies:
@@ -11780,7 +11780,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
picomatch: 2.3.2
mime-db@1.33.0: {}
@@ -11824,11 +11824,11 @@ snapshots:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.12
brace-expansion: 1.1.13
minimatch@5.1.8:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 2.0.3
minimist@1.2.8: {}
@@ -12127,7 +12127,7 @@ snapshots:
path-parse@1.0.7: {}
path-to-regexp@0.1.12: {}
path-to-regexp@0.1.13: {}
path-to-regexp@1.9.0:
dependencies:
@@ -12146,9 +12146,9 @@ snapshots:
picocolors@1.1.1: {}
picomatch@2.3.1: {}
picomatch@2.3.2: {}
picomatch@4.0.3: {}
picomatch@4.0.4: {}
pirates@4.0.7: {}
@@ -12852,7 +12852,7 @@ snapshots:
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
picomatch: 2.3.2
readdirp@4.1.2: {}
@@ -13510,8 +13510,8 @@ snapshots:
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@1.1.1: {}

338
docsv2/custom-ca-setup.md Normal file
View File

@@ -0,0 +1,338 @@
# Custom CA Certificate Setup
Use a private Certificate Authority (CA) with Reflector self-hosted deployments. This covers two scenarios:
1. **Custom local domain** — Serve Reflector over HTTPS on an internal domain (e.g., `reflector.local`) using certs signed by your own CA
2. **Backend CA trust** — Let Reflector's backend services (server, workers, GPU) make HTTPS calls to GPU, LLM, or other internal services behind your private CA
Both can be used independently or together.
## Quick Start
### Generate test certificates
```bash
./scripts/generate-certs.sh reflector.local
```
This creates `certs/` with:
- `ca.key` + `ca.crt` — Root CA (10-year validity)
- `server-key.pem` + `server.pem` — Server certificate (1-year, SAN: domain + localhost + 127.0.0.1)
### Deploy with custom CA + domain
```bash
# Add domain to /etc/hosts on the server (use 127.0.0.1 for local, or server LAN IP for network access)
echo "127.0.0.1 reflector.local" | sudo tee -a /etc/hosts
# Run setup — pass the certs directory
./scripts/setup-selfhosted.sh --gpu --caddy --domain reflector.local --custom-ca certs/
# Trust the CA on your machine (see "Trust the CA" section below)
```
### Deploy with CA trust only (GPU/LLM behind private CA)
```bash
# Only need the CA cert file — no Caddy TLS certs needed
./scripts/setup-selfhosted.sh --hosted --custom-ca /path/to/corporate-ca.crt
```
## How `--custom-ca` Works
The flag accepts a **directory** or a **single file**:
### Directory mode
```bash
--custom-ca certs/
```
Looks for these files by convention:
- `ca.crt` (required) — CA certificate to trust
- `server.pem` + `server-key.pem` (optional) — TLS certificate/key for Caddy
If `server.pem` + `server-key.pem` are found AND `--domain` is provided:
- Caddy serves HTTPS using those certs
- Backend containers trust the CA for outbound calls
If only `ca.crt` is found:
- Backend containers trust the CA for outbound calls
- Caddy is unaffected (uses Let's Encrypt, self-signed, or no Caddy)
### Single file mode
```bash
--custom-ca /path/to/corporate-ca.crt
```
Only injects CA trust into backend containers. No Caddy TLS changes.
## Scenarios
### Scenario 1: Custom local domain
Your Reflector instance runs on an internal network. You want `https://reflector.local` with proper TLS (no browser warnings).
```bash
# 1. Generate certs
./scripts/generate-certs.sh reflector.local
# 2. Add to /etc/hosts on the server
echo "127.0.0.1 reflector.local" | sudo tee -a /etc/hosts
# 3. Deploy
./scripts/setup-selfhosted.sh --gpu --garage --caddy --domain reflector.local --custom-ca certs/
# 4. Trust the CA on your machine (see "Trust the CA" section below)
```
If other machines on the network need to access it, add the server's LAN IP to `/etc/hosts` on those machines instead:
```bash
echo "192.168.1.100 reflector.local" | sudo tee -a /etc/hosts
```
And include that IP as an extra SAN when generating certs:
```bash
./scripts/generate-certs.sh reflector.local "IP:192.168.1.100"
```
### Scenario 2: GPU/LLM behind corporate CA
Your GPU or LLM server (e.g., `https://gpu.internal.corp`) uses certificates signed by your corporate CA. Reflector's backend needs to trust that CA for outbound HTTPS calls.
```bash
# Get the CA certificate from your IT team (PEM format)
# Then deploy — Caddy can still use Let's Encrypt or self-signed
./scripts/setup-selfhosted.sh --hosted --garage --caddy --custom-ca /path/to/corporate-ca.crt
```
This works because:
- **TLS cert/key** = "this is my identity" — for Caddy to serve HTTPS to browsers
- **CA cert** = "I trust this authority" — for backend containers to verify outbound connections
Your Reflector frontend can use Let's Encrypt (public domain) or self-signed certs, while the backend trusts a completely different CA for GPU/LLM calls.
### Scenario 3: Both combined (same CA)
Custom domain + GPU/LLM all behind the same CA:
```bash
./scripts/generate-certs.sh reflector.local "DNS:gpu.local"
./scripts/setup-selfhosted.sh --gpu --garage --caddy --domain reflector.local --custom-ca certs/
```
### Scenario 4: Multiple CAs (local domain + remote GPU on different CA)
Your Reflector uses one CA for `reflector.local`, but the GPU host uses a different CA:
```bash
# Your local domain setup
./scripts/generate-certs.sh reflector.local
# Deploy with your CA + trust the GPU host's CA too
./scripts/setup-selfhosted.sh --hosted --garage --caddy \
--domain reflector.local \
--custom-ca certs/ \
--extra-ca /path/to/gpu-machine-ca.crt
```
`--extra-ca` appends additional CA certs to the trust bundle. Backend containers trust ALL CAs — your local domain AND the GPU host's certs both work.
You can repeat `--extra-ca` for multiple remote services:
```bash
--extra-ca /path/to/gpu-ca.crt --extra-ca /path/to/llm-ca.crt
```
For setting up a dedicated GPU host, see [Standalone GPU Host Setup](gpu-host-setup.md).
## Trust the CA on Client Machines
After deploying, clients need to trust the CA to avoid browser warnings.
### macOS
```bash
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain certs/ca.crt
```
### Linux (Ubuntu/Debian)
```bash
sudo cp certs/ca.crt /usr/local/share/ca-certificates/reflector-ca.crt
sudo update-ca-certificates
```
### Linux (RHEL/Fedora)
```bash
sudo cp certs/ca.crt /etc/pki/ca-trust/source/anchors/reflector-ca.crt
sudo update-ca-trust
```
### Windows (PowerShell as admin)
```powershell
Import-Certificate -FilePath .\certs\ca.crt -CertStoreLocation Cert:\LocalMachine\Root
```
### Firefox (all platforms)
Firefox uses its own certificate store:
1. Settings > Privacy & Security > View Certificates
2. Authorities tab > Import
3. Select `ca.crt` and check "Trust this CA to identify websites"
## How It Works Internally
### Docker entrypoint CA injection
Each backend container (server, worker, beat, hatchet workers, GPU) has an entrypoint script (`docker-entrypoint.sh`) that:
1. Checks if a CA cert is mounted at `/usr/local/share/ca-certificates/custom-ca.crt`
2. If present, runs `update-ca-certificates` to create a **combined bundle** (system CAs + custom CA)
3. Sets environment variables so all Python/gRPC libraries use the combined bundle:
| Env var | Covers |
|---------|--------|
| `SSL_CERT_FILE` | httpx, OpenAI SDK, llama-index, Python ssl module |
| `REQUESTS_CA_BUNDLE` | requests library (transitive dependencies) |
| `CURL_CA_BUNDLE` | curl CLI (container healthchecks) |
Note: `GRPC_DEFAULT_SSL_ROOTS_FILE_PATH` is intentionally NOT set. Setting it causes grpcio to attempt TLS on internal Hatchet gRPC connections that run without TLS, resulting in handshake failures. The internal Hatchet connection uses `HATCHET_CLIENT_TLS_STRATEGY=none` (plaintext).
When no CA cert is mounted, the entrypoint is a no-op — containers behave exactly as before.
### Why this replaces manual certifi patching
Previously, the workaround for trusting a private CA in Python was to patch certifi's bundle directly:
```bash
# OLD approach — fragile, do NOT use
cat custom-ca.crt >> $(python -c "import certifi; print(certifi.where())")
```
This breaks whenever certifi is updated (any `pip install`/`uv sync` overwrites the bundle and the CA is lost).
Our entrypoint approach is permanent because:
1. `SSL_CERT_FILE` is checked by Python's `ssl.create_default_context()` **before** falling back to `certifi.where()`. When set, certifi's bundle is never read.
2. `REQUESTS_CA_BUNDLE` similarly overrides certifi for the `requests` library.
3. The CA is injected at container startup (runtime), not baked into the Python environment. It survives image rebuilds, dependency updates, and `uv sync`.
```
Python SSL lookup chain:
ssl.create_default_context()
→ SSL_CERT_FILE env var? → YES → use combined bundle (system + custom CA) ✓
→ (certifi.where() is never reached)
```
This covers all outbound HTTPS calls: httpx (transcription, diarization, translation, webhooks), OpenAI SDK (transcription), llama-index (LLM/summarization), and requests (transitive dependencies).
### Compose override
The setup script generates `docker-compose.ca.yml` which mounts the CA cert into every backend container as a read-only bind mount. This file is:
- Only generated when `--custom-ca` is passed
- Deleted on re-runs without `--custom-ca` (prevents stale overrides)
- Added to `.gitignore`
### Node.js (frontend)
The web container uses `NODE_EXTRA_CA_CERTS` which **adds** to Node's trust store (unlike Python's `SSL_CERT_FILE` which replaces it). This is set via the compose override.
## Generate Your Own CA (Manual)
If you prefer not to use `generate-certs.sh`:
```bash
# 1. Create CA
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-out ca.crt -subj "/CN=My CA/O=My Organization"
# 2. Create server key
openssl genrsa -out server-key.pem 2048
# 3. Create CSR with SANs
openssl req -new -key server-key.pem -out server.csr \
-subj "/CN=reflector.local" \
-addext "subjectAltName=DNS:reflector.local,DNS:localhost,IP:127.0.0.1"
# 4. Sign with CA
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out server.pem -days 365 -sha256 \
-copy_extensions copyall
# 5. Clean up
rm server.csr ca.srl
```
## Using Existing Corporate Certificates
If your organization already has a CA:
1. Get the CA certificate in PEM format from your IT team
2. If you have a PKCS#12 (.p12/.pfx) bundle, extract the CA cert:
```bash
openssl pkcs12 -in bundle.p12 -cacerts -nokeys -out ca.crt
```
3. If you have multiple intermediate CAs, concatenate them into one PEM file:
```bash
cat intermediate-ca.crt root-ca.crt > ca.crt
```
## Troubleshooting
### Browser: "Your connection is not private"
The CA is not trusted on the client machine. See "Trust the CA" section above.
Check certificate expiry:
```bash
openssl x509 -noout -dates -in certs/server.pem
```
### Backend: `SSL: CERTIFICATE_VERIFY_FAILED`
CA cert not mounted or not loaded. Check inside the container:
```bash
docker compose exec server env | grep SSL_CERT_FILE
docker compose exec server python -c "
import ssl, os
print('SSL_CERT_FILE:', os.environ.get('SSL_CERT_FILE', 'not set'))
ctx = ssl.create_default_context()
print('CA certs loaded:', ctx.cert_store_stats())
"
```
### Caddy: "certificate is not valid for any names"
Domain in Caddyfile doesn't match the certificate's SAN/CN. Check:
```bash
openssl x509 -noout -text -in certs/server.pem | grep -A1 "Subject Alternative Name"
```
### Certificate chain issues
If you have intermediate CAs, concatenate them into `server.pem`:
```bash
cat server-cert.pem intermediate-ca.pem > certs/server.pem
```
Verify the chain:
```bash
openssl verify -CAfile certs/ca.crt certs/server.pem
```
### Certificate renewal
Custom CA certs are NOT auto-renewed (unlike Let's Encrypt). Replace cert files and restart:
```bash
# Replace certs
cp new-server.pem certs/server.pem
cp new-server-key.pem certs/server-key.pem
# Restart Caddy to pick up new certs
docker compose restart caddy
```

294
docsv2/gpu-host-setup.md Normal file
View File

@@ -0,0 +1,294 @@
# Standalone GPU Host Setup
Deploy Reflector's GPU transcription/diarization/translation service on a dedicated machine, separate from the main Reflector instance. Useful when:
- Your GPU machine is on a different network than the Reflector server
- You want to share one GPU service across multiple Reflector instances
- The GPU machine has special hardware/drivers that can't run the full stack
- You need to scale GPU processing independently
## Architecture
```
┌─────────────────────┐ HTTPS ┌────────────────────┐
│ Reflector Server │ ────────────────────── │ GPU Host │
│ (server, worker, │ TRANSCRIPT_URL │ (transcription, │
│ web, postgres, │ DIARIZATION_URL │ diarization, │
│ redis, hatchet) │ TRANSLATE_URL │ translation) │
│ │ │ │
│ setup-selfhosted.sh │ │ setup-gpu-host.sh │
│ --hosted │ │ │
└─────────────────────┘ └────────────────────┘
```
The GPU service is a standalone FastAPI app that exposes transcription, diarization, translation, and audio padding endpoints. It has **no dependencies** on PostgreSQL, Redis, Hatchet, or any other Reflector service.
## Quick Start
### On the GPU machine
```bash
git clone <reflector-repo>
cd reflector
# Set HuggingFace token (required for diarization models)
export HF_TOKEN=your-huggingface-token
# Deploy with HTTPS (Let's Encrypt)
./scripts/setup-gpu-host.sh --domain gpu.example.com --api-key my-secret-key
# Or deploy with custom CA
./scripts/generate-certs.sh gpu.local
./scripts/setup-gpu-host.sh --domain gpu.local --custom-ca certs/ --api-key my-secret-key
```
### On the Reflector machine
```bash
# If the GPU host uses a custom CA, trust it
./scripts/setup-selfhosted.sh --hosted --garage --caddy \
--extra-ca /path/to/gpu-machine-ca.crt
# Or if you already have --custom-ca for your local domain
./scripts/setup-selfhosted.sh --hosted --garage --caddy \
--domain reflector.local --custom-ca certs/ \
--extra-ca /path/to/gpu-machine-ca.crt
```
Then configure `server/.env` to point to the GPU host:
```bash
TRANSCRIPT_BACKEND=modal
TRANSCRIPT_URL=https://gpu.example.com
TRANSCRIPT_MODAL_API_KEY=my-secret-key
DIARIZATION_BACKEND=modal
DIARIZATION_URL=https://gpu.example.com
DIARIZATION_MODAL_API_KEY=my-secret-key
TRANSLATION_BACKEND=modal
TRANSLATE_URL=https://gpu.example.com
TRANSLATION_MODAL_API_KEY=my-secret-key
```
## Script Options
```
./scripts/setup-gpu-host.sh [OPTIONS]
Options:
--domain DOMAIN Domain name for HTTPS (Let's Encrypt or custom cert)
--custom-ca PATH Custom CA (directory or single PEM file)
--extra-ca FILE Additional CA cert to trust (repeatable)
--api-key KEY API key to protect the service (strongly recommended)
--cpu CPU-only mode (no NVIDIA GPU required)
--port PORT Host port (default: 443 with Caddy, 8000 without)
```
## Deployment Scenarios
### Public internet with Let's Encrypt
GPU machine has a public IP and domain:
```bash
./scripts/setup-gpu-host.sh --domain gpu.example.com --api-key my-secret-key
```
Requirements:
- DNS A record: `gpu.example.com` → GPU machine's public IP
- Ports 80 and 443 open
- Caddy auto-provisions Let's Encrypt certificate
### Internal network with custom CA
GPU machine on a private network:
```bash
# Generate certs on the GPU machine
./scripts/generate-certs.sh gpu.internal "IP:192.168.1.200"
# Deploy
./scripts/setup-gpu-host.sh --domain gpu.internal --custom-ca certs/ --api-key my-secret-key
```
On each machine that connects (including the Reflector server), add DNS:
```bash
echo "192.168.1.200 gpu.internal" | sudo tee -a /etc/hosts
```
### IP-only (no domain)
No domain needed — just use the machine's IP:
```bash
./scripts/setup-gpu-host.sh --api-key my-secret-key
```
Caddy is not used; the GPU service runs directly on port 8000 (HTTP). For HTTPS without a domain, the Reflector machine connects via `http://<GPU_IP>:8000`.
### CPU-only (no NVIDIA GPU)
Works on any machine — transcription will be slower:
```bash
./scripts/setup-gpu-host.sh --cpu --domain gpu.example.com --api-key my-secret-key
```
## DNS Resolution
The Reflector server must be able to reach the GPU host by name or IP.
| Setup | DNS Method | TRANSCRIPT_URL example |
|-------|------------|----------------------|
| Public domain | DNS A record | `https://gpu.example.com` |
| Internal domain | `/etc/hosts` on both machines | `https://gpu.internal` |
| IP only | No DNS needed | `http://192.168.1.200:8000` |
For internal domains, add the GPU machine's IP to `/etc/hosts` on the Reflector machine:
```bash
echo "192.168.1.200 gpu.internal" | sudo tee -a /etc/hosts
```
If the Reflector server runs in Docker, the containers resolve DNS from the host (Docker's default DNS behavior). So adding to the host's `/etc/hosts` is sufficient.
## Multi-CA Setup
When your Reflector instance has its own CA (for `reflector.local`) and the GPU host has a different CA:
**On the GPU machine:**
```bash
./scripts/generate-certs.sh gpu.local
./scripts/setup-gpu-host.sh --domain gpu.local --custom-ca certs/ --api-key my-key
```
**On the Reflector machine:**
```bash
# Your local CA for reflector.local + the GPU host's CA
./scripts/setup-selfhosted.sh --hosted --garage --caddy \
--domain reflector.local \
--custom-ca certs/ \
--extra-ca /path/to/gpu-machine-ca.crt
```
The `--extra-ca` flag appends the GPU host's CA to the trust bundle. Backend containers trust both CAs — your local domain works AND outbound calls to the GPU host succeed.
You can repeat `--extra-ca` for multiple remote services:
```bash
--extra-ca /path/to/gpu-ca.crt --extra-ca /path/to/llm-ca.crt
```
## API Key Authentication
The GPU service uses Bearer token authentication via `REFLECTOR_GPU_APIKEY`:
```bash
# Test from the Reflector machine
curl -s https://gpu.example.com/docs # No auth needed for docs
curl -s -X POST https://gpu.example.com/v1/audio/transcriptions \
-H "Authorization: Bearer <my-secret-key>" \ #gitleaks:allow
-F "file=@audio.wav"
```
If `REFLECTOR_GPU_APIKEY` is not set, the service accepts all requests (open access). Always use `--api-key` for internet-facing deployments.
The same key goes in Reflector's `server/.env` as `TRANSCRIPT_MODAL_API_KEY` and `DIARIZATION_MODAL_API_KEY`.
## Files
| File | Checked in? | Purpose |
|------|-------------|---------|
| `docker-compose.gpu-host.yml` | Yes | Static compose file with profiles (`gpu`, `cpu`, `caddy`) |
| `.env.gpu-host` | No (generated) | Environment variables (HF_TOKEN, API key, ports) |
| `Caddyfile.gpu-host` | No (generated) | Caddy config (only when using HTTPS) |
| `docker-compose.gpu-ca.yml` | No (generated) | CA cert mounts override (only with --custom-ca) |
| `certs/` | No (generated) | Staged certificates (when using --custom-ca) |
The compose file is checked into the repo — you can read it to understand exactly what runs. The script only generates env vars, Caddyfile, and CA overrides. Profiles control which service starts:
```bash
# What the script does under the hood:
docker compose -f docker-compose.gpu-host.yml --profile gpu --profile caddy \
--env-file .env.gpu-host up -d
# CPU mode:
docker compose -f docker-compose.gpu-host.yml --profile cpu --profile caddy \
--env-file .env.gpu-host up -d
```
Both `gpu` and `cpu` services get the network alias `transcription`, so Caddy's config works with either.
## Management
```bash
# View logs
docker compose -f docker-compose.gpu-host.yml --profile gpu logs -f gpu
# Restart
docker compose -f docker-compose.gpu-host.yml --profile gpu restart gpu
# Stop
docker compose -f docker-compose.gpu-host.yml --profile gpu --profile caddy down
# Re-run setup
./scripts/setup-gpu-host.sh [same flags]
# Rebuild after code changes
docker compose -f docker-compose.gpu-host.yml --profile gpu build gpu
docker compose -f docker-compose.gpu-host.yml --profile gpu up -d gpu
```
If you deployed with `--custom-ca`, include the CA override in manual commands:
```bash
docker compose -f docker-compose.gpu-host.yml -f docker-compose.gpu-ca.yml \
--profile gpu logs -f gpu
```
## Troubleshooting
### GPU service won't start
Check logs:
```bash
docker compose -f docker-compose.gpu-host.yml logs gpu
```
Common causes:
- NVIDIA driver not installed or `nvidia-container-toolkit` missing
- `HF_TOKEN` not set (diarization model download fails)
- Port already in use
### Reflector can't connect to GPU host
From the Reflector machine:
```bash
# Test HTTPS connectivity
curl -v https://gpu.example.com/docs
# If using custom CA, test with explicit CA
curl --cacert /path/to/gpu-ca.crt https://gpu.internal/docs
```
From inside the Reflector container:
```bash
docker compose exec server python -c "
import httpx
r = httpx.get('https://gpu.internal/docs')
print(r.status_code)
"
```
### SSL: CERTIFICATE_VERIFY_FAILED
The Reflector backend doesn't trust the GPU host's CA. Fix:
```bash
# Re-run Reflector setup with the GPU host's CA
./scripts/setup-selfhosted.sh --hosted --extra-ca /path/to/gpu-ca.crt
```
### Diarization returns errors
- Accept pyannote model licenses on HuggingFace:
- https://huggingface.co/pyannote/speaker-diarization-3.1
- https://huggingface.co/pyannote/segmentation-3.0
- Verify `HF_TOKEN` is set in `.env.gpu-host`

View File

@@ -24,6 +24,8 @@ This document explains the internals of the self-hosted deployment: how the setu
The self-hosted deployment runs the entire Reflector platform on a single server using Docker Compose. A single bash script (`scripts/setup-selfhosted.sh`) handles all configuration and orchestration. The key design principles are:
- **One command to deploy** — flags select which features to enable
- **Config memory** — CLI args are saved to `data/.selfhosted-last-args`; re-run with no flags to replay
- **Per-service overrides** — individual ML backends (transcript, diarization, translation, padding, mixdown) can be overridden independently from the base mode
- **Idempotent** — safe to re-run without losing existing configuration
- **Profile-based composition** — Docker Compose profiles activate optional services
- **No external dependencies required** — with `--garage` and `--ollama-*`, everything runs locally
@@ -61,8 +63,9 @@ Creates or updates the backend environment file from `server/.env.selfhosted.exa
- **Infrastructure** — PostgreSQL URL, Redis host, Celery broker (all pointing to Docker-internal hostnames)
- **Public URLs** — `BASE_URL` and `CORS_ORIGIN` computed from the domain (if `--domain`), IP (if detected on Linux), or `localhost`
- **WebRTC** — `WEBRTC_HOST` set to the server's LAN IP so browsers can reach UDP ICE candidates
- **Specialized models** — always points to `http://transcription:8000` (the Docker network alias shared by GPU and CPU containers)
- **HuggingFace token** — prompts interactively for pyannote model access; writes to root `.env` so Docker Compose can inject it into GPU/CPU containers
- **ML backends (per-service)** — Each ML service (transcript, diarization, translation, padding, mixdown) is configured independently using "effective backends" (`EFF_TRANSCRIPT`, `EFF_DIARIZATION`, `EFF_TRANSLATION`, `EFF_PADDING`, `EFF_MIXDOWN`). These are resolved from the base mode default + any `--transcript`/`--diarization`/`--translation`/`--padding`/`--mixdown` overrides. For `modal` backends, the URL is `http://transcription:8000` (GPU mode), user-provided (hosted mode), or read from existing env (CPU mode with override). For CPU backends, no URL is needed (in-process). If a service is overridden to `modal` in CPU mode without a URL configured, the script warns the user to set `TRANSCRIPT_URL` in `server/.env`
- **CPU timeouts** — `TRANSCRIPT_FILE_TIMEOUT` and `DIARIZATION_FILE_TIMEOUT` are increased to 3600s only for services actually using CPU backends (whisper/pyannote), not blanket for the whole mode
- **HuggingFace token** — prompted when diarization uses `pyannote` (in-process) or when GPU mode is active (GPU container needs it). Writes to root `.env` so Docker Compose can inject it into GPU/CPU containers
- **LLM** — if `--ollama-*` is used, configures `LLM_URL` pointing to the Ollama container. Otherwise, warns that the user needs to configure an external LLM
- **Public mode** — sets `PUBLIC_MODE=true` so the app is accessible without authentication by default
- **Password auth** — if `--password` is passed, sets `AUTH_BACKEND=password`, `PUBLIC_MODE=false`, `ADMIN_EMAIL=admin@localhost`, and `ADMIN_PASSWORD_HASH` (the hash generated in Step 1). The admin user is provisioned in the database on container startup via `runserver.sh`
@@ -228,11 +231,19 @@ Both the `gpu` and `cpu` services define a Docker network alias of `transcriptio
Environment variables flow through multiple layers. Understanding this prevents confusion when debugging:
```
Flags (--gpu, --garage, etc.)
CLI args (--gpu, --garage, --padding modal, --mixdown modal, etc.)
├── setup-selfhosted.sh interprets flags
├── Config memory: saved to data/.selfhosted-last-args
│ (replayed on next run if no args provided)
├── setup-selfhosted.sh resolves effective backends:
│ EFF_TRANSCRIPT = override or base mode default
│ EFF_DIARIZATION = override or base mode default
│ EFF_TRANSLATION = override or base mode default
│ EFF_PADDING = override or base mode default
│ EFF_MIXDOWN = override or base mode default
│ │
│ ├── Writes server/.env (backend config)
│ ├── Writes server/.env (backend config, per-service backends)
│ ├── Writes www/.env (frontend config)
│ ├── Writes .env (HF_TOKEN for compose interpolation)
│ └── Writes Caddyfile (proxy routes)

View File

@@ -70,7 +70,7 @@ That's it. The script generates env files, secrets, starts all containers, waits
## ML Processing Modes (Required)
Pick `--gpu`, `--cpu`, or `--hosted`. This determines how **transcription, diarization, translation, and audio padding** run:
Pick `--gpu`, `--cpu`, or `--hosted`. This determines how **transcription, diarization, translation, audio padding, and audio mixdown** run:
| Flag | What it does | Requires |
|------|-------------|----------|
@@ -158,6 +158,56 @@ Without `--caddy` or `--domain`, no ports are exposed. Point your own reverse pr
**Without a domain:** `--caddy` alone uses a self-signed certificate. Browsers will show a security warning that must be accepted.
## Per-Service Backend Overrides
Override individual ML services without changing the base mode. Useful when you want most services on one backend but need specific services on another.
| Flag | Valid backends | Default (`--gpu`/`--hosted`) | Default (`--cpu`) |
|------|---------------|------------------------------|-------------------|
| `--transcript BACKEND` | `whisper`, `modal` | `modal` | `whisper` |
| `--diarization BACKEND` | `pyannote`, `modal` | `modal` | `pyannote` |
| `--translation BACKEND` | `marian`, `modal`, `passthrough` | `modal` | `marian` |
| `--padding BACKEND` | `pyav`, `modal` | `modal` | `pyav` |
| `--mixdown BACKEND` | `pyav`, `modal` | `modal` | `pyav` |
**Examples:**
```bash
# CPU base, but use a remote modal service for padding only
./scripts/setup-selfhosted.sh --cpu --padding modal --garage --caddy
# GPU base, but skip translation entirely (passthrough)
./scripts/setup-selfhosted.sh --gpu --translation passthrough --garage --caddy
# CPU base with remote modal diarization and translation
./scripts/setup-selfhosted.sh --cpu --diarization modal --translation modal --garage
```
When overriding a service to `modal` in `--cpu` mode, the script will warn you to configure the service URL (`TRANSCRIPT_URL` etc.) in `server/.env` to point to your GPU service, then re-run.
When overriding a service to a CPU backend (e.g., `--transcript whisper`) in `--gpu` mode, that service runs in-process on the server/worker containers while the GPU container still serves the remaining `modal` services.
## Config Memory (No-Flag Re-run)
After a successful run, the script saves your CLI arguments to `data/.selfhosted-last-args`. On subsequent runs with no arguments, the saved configuration is automatically replayed:
```bash
# First run — saves the config
./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy
# Later re-runs — same config, no flags needed
./scripts/setup-selfhosted.sh
# => "No flags provided — replaying saved configuration:"
# => " --gpu --ollama-gpu --garage --caddy"
```
To change the configuration, pass new flags — they override and replace the saved config:
```bash
# Switch to CPU mode with overrides — this becomes the new saved config
./scripts/setup-selfhosted.sh --cpu --padding modal --garage --caddy
```
## What the Script Does
1. **Prerequisites check** — Docker, NVIDIA GPU (if needed), compose file exists
@@ -189,6 +239,8 @@ Without `--caddy` or `--domain`, no ports are exposed. Point your own reverse pr
| `TRANSCRIPT_URL` | Specialized model endpoint | `http://transcription:8000` |
| `PADDING_BACKEND` | Audio padding backend (`pyav` or `modal`) | `modal` (selfhosted), `pyav` (default) |
| `PADDING_URL` | Audio padding endpoint (when `PADDING_BACKEND=modal`) | `http://transcription:8000` |
| `MIXDOWN_BACKEND` | Audio mixdown backend (`pyav` or `modal`) | `modal` (selfhosted), `pyav` (default) |
| `MIXDOWN_URL` | Audio mixdown endpoint (when `MIXDOWN_BACKEND=modal`) | `http://transcription:8000` |
| `LLM_URL` | OpenAI-compatible LLM endpoint | Auto-set for Ollama modes |
| `LLM_API_KEY` | LLM API key | `not-needed` for Ollama |
| `LLM_MODEL` | LLM model name | `qwen2.5:14b` for Ollama (override with `--llm-model`) |
@@ -199,6 +251,11 @@ Without `--caddy` or `--domain`, no ports are exposed. Point your own reverse pr
| `DAILY_SUBDOMAIN` | Daily.co subdomain | *(unset)* |
| `DAILYCO_STORAGE_AWS_ACCESS_KEY_ID` | AWS access key for reading Daily's recording bucket | *(unset)* |
| `DAILYCO_STORAGE_AWS_SECRET_ACCESS_KEY` | AWS secret key for reading Daily's recording bucket | *(unset)* |
| `ZULIP_REALM` | Zulip server hostname (e.g. `zulip.example.com`) | *(unset)* |
| `ZULIP_API_KEY` | Zulip bot API key | *(unset)* |
| `ZULIP_BOT_EMAIL` | Zulip bot email address | *(unset)* |
| `ZULIP_DAG_STREAM` | Zulip stream for pipeline failure alerts | *(unset)* |
| `ZULIP_DAG_TOPIC` | Zulip topic for pipeline failure alerts | *(unset)* |
| `HATCHET_CLIENT_TOKEN` | Hatchet API token (auto-generated) | *(unset)* |
| `HATCHET_CLIENT_SERVER_URL` | Hatchet server URL | Auto-set when Daily.co configured |
| `HATCHET_CLIENT_HOST_PORT` | Hatchet gRPC address | Auto-set when Daily.co configured |
@@ -248,6 +305,48 @@ TRANSCRIPT_STORAGE_AWS_REGION=us-east-1
TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL=http://minio:9000
```
### S3 IAM Permissions Reference
Reflector uses up to 3 separate S3 credential sets, each scoped to a specific bucket. When using AWS IAM in production, each key should have only the permissions it needs.
**Transcript storage key** (`TRANSCRIPT_STORAGE_AWS_*`) — the main bucket for processed files:
```json
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::reflector-media/*", "arn:aws:s3:::reflector-media"]
}
```
Used for: processed MP3 audio, waveform JSON, temporary pipeline files. Deletions happen during trash "Destroy", consent-denied cleanup, and public mode data retention.
**Daily.co worker key** (`DAILYCO_STORAGE_AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY`) — for reading and cleaning up Daily recordings:
```json
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::your-daily-bucket/*", "arn:aws:s3:::your-daily-bucket"]
}
```
Used for: downloading multitrack recording files for processing, deleting track files and composed video on consent denial or trash destroy. No `s3:PutObject` needed — Daily's own API writes via the Role ARN.
**Whereby worker key** (`WHEREBY_STORAGE_AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY`) — same pattern as Daily:
```json
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::your-whereby-bucket/*", "arn:aws:s3:::your-whereby-bucket"]
}
```
> **Fallback behavior:** If platform-specific worker keys are not set, Reflector falls back to the transcript storage master key with a bucket override. This means the master key would need cross-bucket access to the Daily/Whereby buckets. For least-privilege, configure platform-specific keys so each only accesses its own bucket.
> **Garage / single-bucket setups:** When using Garage or a single S3 bucket for everything, one master key with full permissions on that bucket is sufficient. The IAM scoping above only matters when using separate buckets per platform (typical in AWS production).
## What Authentication Enables
By default, Reflector runs in **public mode** (`AUTH_BACKEND=none`, `PUBLIC_MODE=true`) — anyone can create and view transcripts without logging in. Transcripts are anonymous (not linked to any user) and cannot be edited or deleted after creation.
@@ -571,9 +670,9 @@ docker compose -f docker-compose.selfhosted.yml exec gpu curl http://localhost:8
## Updating
```bash
# Option A: Pull latest prebuilt images and restart
# Option A: Pull latest prebuilt images and restart (replays saved config automatically)
docker compose -f docker-compose.selfhosted.yml down
./scripts/setup-selfhosted.sh <same-flags-as-before>
./scripts/setup-selfhosted.sh
# Option B: Build from source (after git pull) and restart
git pull
@@ -584,6 +683,8 @@ docker compose -f docker-compose.selfhosted.yml down
docker compose -f docker-compose.selfhosted.yml build gpu # or cpu
```
> **Note on config memory:** Running with no flags replays the saved config from your last run. Running with *any* flags replaces the saved config entirely — the script always saves the complete set of flags you provide. See [Config Memory](#config-memory-no-flag-re-run).
The setup script is idempotent — it won't overwrite existing secrets or env vars that are already set.
## Architecture Overview

View File

@@ -114,8 +114,8 @@ modal secret create reflector-gpu REFLECTOR_GPU_APIKEY="$API_KEY"
# --- Deploy Functions ---
echo ""
echo "Deploying transcriber (Whisper)..."
TRANSCRIBER_URL=$(modal deploy reflector_transcriber.py 2>&1 | grep -o 'https://[^ ]*web.modal.run' | head -1)
echo "Deploying transcriber (Parakeet)..."
TRANSCRIBER_URL=$(modal deploy reflector_transcriber_parakeet.py 2>&1 | grep -o 'https://[^ ]*web.modal.run' | head -1)
if [ -z "$TRANSCRIBER_URL" ]; then
echo "Error: Failed to deploy transcriber. Check Modal dashboard for details."
exit 1
@@ -132,13 +132,22 @@ fi
echo " -> $DIARIZER_URL"
echo ""
echo "Deploying padding (CPU audio processing via Modal SDK)..."
modal deploy reflector_padding.py
if [ $? -ne 0 ]; then
echo "Deploying padding (CPU audio processing)..."
PADDING_URL=$(modal deploy reflector_padding.py 2>&1 | grep -o 'https://[^ ]*web.modal.run' | head -1)
if [ -z "$PADDING_URL" ]; then
echo "Error: Failed to deploy padding. Check Modal dashboard for details."
exit 1
fi
echo " -> reflector-padding.pad_track (Modal SDK function)"
echo " -> $PADDING_URL"
echo ""
echo "Deploying mixdown (CPU multi-track audio mixing)..."
MIXDOWN_URL=$(modal deploy reflector_mixdown.py 2>&1 | grep -o 'https://[^ ]*web.modal.run' | head -1)
if [ -z "$MIXDOWN_URL" ]; then
echo "Error: Failed to deploy mixdown. Check Modal dashboard for details."
exit 1
fi
echo " -> $MIXDOWN_URL"
# --- Output Configuration ---
echo ""
@@ -157,5 +166,11 @@ echo "DIARIZATION_BACKEND=modal"
echo "DIARIZATION_URL=$DIARIZER_URL"
echo "DIARIZATION_MODAL_API_KEY=$API_KEY"
echo ""
echo "# Padding uses Modal SDK (requires MODAL_TOKEN_ID/SECRET in worker containers)"
echo "PADDING_BACKEND=modal"
echo "PADDING_URL=$PADDING_URL"
echo "PADDING_MODAL_API_KEY=$API_KEY"
echo ""
echo "MIXDOWN_BACKEND=modal"
echo "MIXDOWN_URL=$MIXDOWN_URL"
echo "MIXDOWN_MODAL_API_KEY=$API_KEY"
echo "# --- End Modal Configuration ---"

View File

@@ -113,12 +113,14 @@ def download_pyannote_audio():
diarizer_image = (
modal.Image.debian_slim(python_version="3.10")
modal.Image.from_registry(
"nvidia/cuda:11.8.0-cudnn8-devel-ubuntu22.04", add_python="3.10"
)
.pip_install(
"pyannote.audio==3.1.0",
"requests",
"onnx",
"torchaudio",
"torchaudio==2.0.1",
"onnxruntime-gpu",
"torch==2.0.0",
"transformers==4.34.0",
@@ -133,14 +135,6 @@ diarizer_image = (
secrets=[modal.Secret.from_name("hf_token")],
)
.run_function(migrate_cache_llm)
.env(
{
"LD_LIBRARY_PATH": (
"/usr/local/lib/python3.10/site-packages/nvidia/cudnn/lib/:"
"/opt/conda/lib/python3.10/site-packages/nvidia/cublas/lib/"
)
}
)
)

View File

@@ -0,0 +1,385 @@
"""
Reflector GPU backend - audio mixdown
=====================================
CPU-intensive multi-track audio mixdown service.
Mixes N audio tracks into a single MP3 using PyAV amix filter graph.
IMPORTANT: This mixdown logic is duplicated from server/reflector/utils/audio_mixdown.py
for Modal deployment isolation (Modal can't import from server/reflector/). If you modify
the PyAV filter graph or mixdown algorithm, you MUST update both:
- gpu/modal_deployments/reflector_mixdown.py (this file)
- server/reflector/utils/audio_mixdown.py
Constants duplicated from server/reflector/utils/audio_constants.py for same reason.
"""
import os
import tempfile
from fractions import Fraction
import asyncio
import modal
S3_TIMEOUT = 120 # Higher than padding (60s) — multiple track downloads
MIXDOWN_TIMEOUT = 1200 + (S3_TIMEOUT * 2) # 1440s total
SCALEDOWN_WINDOW = 60
DISCONNECT_CHECK_INTERVAL = 2
app = modal.App("reflector-mixdown")
# CPU-based image (mixdown is CPU-bound, no GPU needed)
image = (
modal.Image.debian_slim(python_version="3.12")
.apt_install("ffmpeg") # Required by PyAV
.pip_install(
"av==13.1.0", # PyAV for audio processing
"requests==2.32.3", # HTTP for presigned URL downloads/uploads
"fastapi==0.115.12", # API framework
)
)
@app.function(
cpu=4.0, # Higher than padding (2.0) for multi-track mixing
timeout=MIXDOWN_TIMEOUT,
scaledown_window=SCALEDOWN_WINDOW,
image=image,
secrets=[modal.Secret.from_name("reflector-gpu")],
)
@modal.asgi_app()
def web():
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
class MixdownRequest(BaseModel):
track_urls: list[str]
output_url: str
target_sample_rate: int | None = None
offsets_seconds: list[float] | None = None
class MixdownResponse(BaseModel):
size: int
duration_ms: float = 0.0
cancelled: bool = False
web_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"},
)
@web_app.post("/mixdown", dependencies=[Depends(apikey_auth)])
async def mixdown_endpoint(request: Request, req: MixdownRequest) -> MixdownResponse:
"""Modal web endpoint for mixing audio tracks with disconnect detection."""
import logging
import threading
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
valid_urls = [u for u in req.track_urls if u]
if not valid_urls:
raise HTTPException(status_code=400, detail="No valid track URLs provided")
if req.offsets_seconds is not None:
if len(req.offsets_seconds) != len(req.track_urls):
raise HTTPException(
status_code=400,
detail=f"offsets_seconds length ({len(req.offsets_seconds)}) "
f"must match track_urls ({len(req.track_urls)})",
)
if any(o > 18000 for o in req.offsets_seconds):
raise HTTPException(status_code=400, detail="offsets_seconds exceeds maximum 18000s (5 hours)")
if not req.output_url:
raise HTTPException(status_code=400, detail="output_url cannot be empty")
logger.info(f"Mixdown request: {len(valid_urls)} tracks")
# Thread-safe cancellation flag
cancelled = threading.Event()
async def check_disconnect():
"""Background task to check for client disconnect."""
while not cancelled.is_set():
await asyncio.sleep(DISCONNECT_CHECK_INTERVAL)
if await request.is_disconnected():
logger.warning("Client disconnected, setting cancellation flag")
cancelled.set()
break
disconnect_task = asyncio.create_task(check_disconnect())
try:
result = await asyncio.get_event_loop().run_in_executor(
None, _mixdown_tracks_blocking, req, cancelled, logger
)
return MixdownResponse(**result)
finally:
cancelled.set()
disconnect_task.cancel()
try:
await disconnect_task
except asyncio.CancelledError:
pass
def _mixdown_tracks_blocking(req, cancelled, logger) -> dict:
"""Blocking CPU-bound mixdown work with periodic cancellation checks.
Downloads all tracks, builds PyAV amix filter graph, encodes to MP3,
and uploads the result to the presigned output URL.
"""
import av
import requests
from av.audio.resampler import AudioResampler
import time
temp_dir = tempfile.mkdtemp()
track_paths = []
output_path = None
last_check = time.time()
try:
# --- Download all tracks ---
valid_urls = [u for u in req.track_urls if u]
for i, url in enumerate(valid_urls):
if cancelled.is_set():
logger.info("Cancelled during download phase")
return {"size": 0, "duration_ms": 0.0, "cancelled": True}
logger.info(f"Downloading track {i}")
response = requests.get(url, stream=True, timeout=S3_TIMEOUT)
response.raise_for_status()
track_path = os.path.join(temp_dir, f"track_{i}.webm")
total_bytes = 0
chunk_count = 0
with open(track_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
total_bytes += len(chunk)
chunk_count += 1
if chunk_count % 12 == 0:
now = time.time()
if now - last_check >= DISCONNECT_CHECK_INTERVAL:
if cancelled.is_set():
logger.info(f"Cancelled during track {i} download")
return {"size": 0, "duration_ms": 0.0, "cancelled": True}
last_check = now
track_paths.append(track_path)
logger.info(f"Track {i} downloaded: {total_bytes} bytes")
if not track_paths:
raise ValueError("No tracks downloaded")
# --- Detect sample rate ---
target_sample_rate = req.target_sample_rate
if target_sample_rate is None:
for path in track_paths:
try:
container = av.open(path)
for frame in container.decode(audio=0):
target_sample_rate = frame.sample_rate
container.close()
break
else:
container.close()
continue
break
except Exception:
continue
if target_sample_rate is None:
raise ValueError("Could not detect sample rate from any track")
logger.info(f"Target sample rate: {target_sample_rate}")
# --- Calculate per-input delays ---
input_offsets_seconds = None
if req.offsets_seconds is not None:
input_offsets_seconds = [
req.offsets_seconds[i] for i, url in enumerate(req.track_urls) if url
]
delays_ms = []
if input_offsets_seconds is not None:
base = min(input_offsets_seconds) if input_offsets_seconds else 0.0
delays_ms = [max(0, int(round((o - base) * 1000))) for o in input_offsets_seconds]
else:
delays_ms = [0 for _ in track_paths]
# --- Build filter graph ---
# N abuffer -> optional adelay -> amix -> aformat -> abuffersink
graph = av.filter.Graph()
inputs = []
for idx in range(len(track_paths)):
args = (
f"time_base=1/{target_sample_rate}:"
f"sample_rate={target_sample_rate}:"
f"sample_fmt=s32:"
f"channel_layout=stereo"
)
in_ctx = graph.add("abuffer", args=args, name=f"in{idx}")
inputs.append(in_ctx)
mixer = graph.add("amix", args=f"inputs={len(inputs)}:normalize=0", name="mix")
fmt = graph.add(
"aformat",
args=f"sample_fmts=s32:channel_layouts=stereo:sample_rates={target_sample_rate}",
name="fmt",
)
sink = graph.add("abuffersink", name="out")
for idx, in_ctx in enumerate(inputs):
delay_ms = delays_ms[idx] if idx < len(delays_ms) else 0
if delay_ms > 0:
adelay = graph.add(
"adelay",
args=f"delays={delay_ms}|{delay_ms}:all=1",
name=f"delay{idx}",
)
in_ctx.link_to(adelay)
adelay.link_to(mixer, 0, idx)
else:
in_ctx.link_to(mixer, 0, idx)
mixer.link_to(fmt)
fmt.link_to(sink)
graph.configure()
# --- Open all containers and decode ---
containers = []
output_path = os.path.join(temp_dir, "mixed.mp3")
try:
for path in track_paths:
containers.append(av.open(path))
decoders = [c.decode(audio=0) for c in containers]
active = [True] * len(decoders)
resamplers = [
AudioResampler(format="s32", layout="stereo", rate=target_sample_rate)
for _ in decoders
]
# Open output MP3
out_container = av.open(output_path, "w", format="mp3")
out_stream = out_container.add_stream("libmp3lame", rate=target_sample_rate)
total_duration = 0
while any(active):
# Check cancellation periodically
now = time.time()
if now - last_check >= DISCONNECT_CHECK_INTERVAL:
if cancelled.is_set():
logger.info("Cancelled during mixing")
out_container.close()
return {"size": 0, "duration_ms": 0.0, "cancelled": True}
last_check = now
for i, (dec, is_active) in enumerate(zip(decoders, active)):
if not is_active:
continue
try:
frame = next(dec)
except StopIteration:
active[i] = False
inputs[i].push(None)
continue
if frame.sample_rate != target_sample_rate:
continue
out_frames = resamplers[i].resample(frame) or []
for rf in out_frames:
rf.sample_rate = target_sample_rate
rf.time_base = Fraction(1, target_sample_rate)
inputs[i].push(rf)
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
for packet in out_stream.encode(mixed):
out_container.mux(packet)
total_duration += packet.duration
# Flush filter graph
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
for packet in out_stream.encode(mixed):
out_container.mux(packet)
total_duration += packet.duration
# Flush encoder
for packet in out_stream.encode(None):
out_container.mux(packet)
total_duration += packet.duration
# Calculate duration in ms
last_tb = out_stream.time_base
duration_ms = 0.0
if last_tb and total_duration > 0:
duration_ms = round(float(total_duration * last_tb * 1000), 2)
out_container.close()
finally:
for c in containers:
try:
c.close()
except Exception:
pass
file_size = os.path.getsize(output_path)
logger.info(f"Mixdown complete: {file_size} bytes, {duration_ms}ms")
if cancelled.is_set():
logger.info("Cancelled after mixing, before upload")
return {"size": 0, "duration_ms": 0.0, "cancelled": True}
# --- Upload result ---
logger.info("Uploading mixed audio to S3")
with open(output_path, "rb") as f:
upload_response = requests.put(req.output_url, data=f, timeout=S3_TIMEOUT)
upload_response.raise_for_status()
logger.info(f"Upload complete: {file_size} bytes")
return {"size": file_size, "duration_ms": duration_ms}
finally:
# Cleanup all temp files
for path in track_paths:
if os.path.exists(path):
try:
os.unlink(path)
except Exception as e:
logger.warning(f"Failed to cleanup track file: {e}")
if output_path and os.path.exists(output_path):
try:
os.unlink(output_path)
except Exception as e:
logger.warning(f"Failed to cleanup output file: {e}")
try:
os.rmdir(temp_dir)
except Exception as e:
logger.warning(f"Failed to cleanup temp directory: {e}")
return web_app

View File

@@ -52,10 +52,12 @@ OPUS_DEFAULT_BIT_RATE = 128000
timeout=PADDING_TIMEOUT,
scaledown_window=SCALEDOWN_WINDOW,
image=image,
secrets=[modal.Secret.from_name("reflector-gpu")],
)
@modal.asgi_app()
def web():
from fastapi import FastAPI, Request, HTTPException
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
class PaddingRequest(BaseModel):
@@ -70,7 +72,18 @@ def web():
web_app = FastAPI()
@web_app.post("/pad")
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"},
)
@web_app.post("/pad", dependencies=[Depends(apikey_auth)])
async def pad_track_endpoint(request: Request, req: PaddingRequest) -> PaddingResponse:
"""Modal web endpoint for padding audio tracks with disconnect detection.
"""

View File

@@ -42,6 +42,7 @@ COPY pyproject.toml uv.lock /app/
COPY ./app /app/app
COPY ./main.py /app/
COPY ./runserver.sh /app/
COPY ./docker-entrypoint.sh /app/
# prevent uv failing with too many open files on big cpus
ENV UV_CONCURRENT_INSTALLS=16
@@ -52,6 +53,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
EXPOSE 8000
CMD ["sh", "/app/runserver.sh"]
RUN chmod +x /app/docker-entrypoint.sh
CMD ["sh", "/app/docker-entrypoint.sh"]

View File

@@ -26,6 +26,7 @@ COPY pyproject.toml uv.lock /app/
COPY ./app /app/app
COPY ./main.py /app/
COPY ./runserver.sh /app/
COPY ./docker-entrypoint.sh /app/
# prevent uv failing with too many open files on big cpus
ENV UV_CONCURRENT_INSTALLS=16
@@ -36,4 +37,6 @@ RUN --mount=type=cache,target=/root/.cache/uv \
EXPOSE 8000
CMD ["sh", "/app/runserver.sh"]
RUN chmod +x /app/docker-entrypoint.sh
CMD ["sh", "/app/docker-entrypoint.sh"]

View File

@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from .routers.diarization import router as diarization_router
from .routers.mixdown import router as mixdown_router
from .routers.padding import router as padding_router
from .routers.transcription import router as transcription_router
from .routers.translation import router as translation_router
@@ -29,4 +30,5 @@ def create_app() -> FastAPI:
app.include_router(translation_router)
app.include_router(diarization_router)
app.include_router(padding_router)
app.include_router(mixdown_router)
return app

View File

@@ -0,0 +1,288 @@
"""
Audio mixdown endpoint for selfhosted GPU service.
CPU-intensive multi-track audio mixing service for combining N audio tracks
into a single MP3 using PyAV amix filter graph.
IMPORTANT: This mixdown logic is duplicated from server/reflector/utils/audio_mixdown.py
for deployment isolation (self_hosted can't import from server/reflector/). If you modify
the PyAV filter graph or mixdown algorithm, you MUST update both:
- gpu/self_hosted/app/routers/mixdown.py (this file)
- server/reflector/utils/audio_mixdown.py
Constants duplicated from server/reflector/utils/audio_constants.py for same reason.
"""
import logging
import os
import tempfile
from fractions import Fraction
import av
import requests
from av.audio.resampler import AudioResampler
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..auth import apikey_auth
logger = logging.getLogger(__name__)
router = APIRouter(tags=["mixdown"])
S3_TIMEOUT = 120
class MixdownRequest(BaseModel):
track_urls: list[str]
output_url: str
target_sample_rate: int | None = None
offsets_seconds: list[float] | None = None
class MixdownResponse(BaseModel):
size: int
duration_ms: float = 0.0
cancelled: bool = False
@router.post("/mixdown", dependencies=[Depends(apikey_auth)], response_model=MixdownResponse)
def mixdown_tracks(req: MixdownRequest):
"""Mix multiple audio tracks into single MP3 using PyAV amix filter graph."""
valid_urls = [u for u in req.track_urls if u]
if not valid_urls:
raise HTTPException(status_code=400, detail="No valid track URLs provided")
if req.offsets_seconds is not None:
if len(req.offsets_seconds) != len(req.track_urls):
raise HTTPException(
status_code=400,
detail=f"offsets_seconds length ({len(req.offsets_seconds)}) "
f"must match track_urls ({len(req.track_urls)})",
)
if any(o > 18000 for o in req.offsets_seconds):
raise HTTPException(
status_code=400, detail="offsets_seconds exceeds maximum 18000s (5 hours)"
)
if not req.output_url:
raise HTTPException(status_code=400, detail="output_url cannot be empty")
logger.info("Mixdown request: %d tracks", len(valid_urls))
temp_dir = tempfile.mkdtemp()
track_paths = []
output_path = None
try:
# --- Download all tracks ---
for i, url in enumerate(valid_urls):
logger.info("Downloading track %d", i)
response = requests.get(url, stream=True, timeout=S3_TIMEOUT)
response.raise_for_status()
track_path = os.path.join(temp_dir, f"track_{i}.webm")
total_bytes = 0
with open(track_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
total_bytes += len(chunk)
track_paths.append(track_path)
logger.info("Track %d downloaded: %d bytes", i, total_bytes)
if not track_paths:
raise HTTPException(status_code=400, detail="No tracks could be downloaded")
# --- Detect sample rate ---
target_sample_rate = req.target_sample_rate
if target_sample_rate is None:
for path in track_paths:
try:
container = av.open(path)
for frame in container.decode(audio=0):
target_sample_rate = frame.sample_rate
container.close()
break
else:
container.close()
continue
break
except Exception:
continue
if target_sample_rate is None:
raise HTTPException(
status_code=400, detail="Could not detect sample rate from any track"
)
logger.info("Target sample rate: %d", target_sample_rate)
# --- Calculate per-input delays ---
input_offsets_seconds = None
if req.offsets_seconds is not None:
input_offsets_seconds = [
req.offsets_seconds[i] for i, url in enumerate(req.track_urls) if url
]
delays_ms = []
if input_offsets_seconds is not None:
base = min(input_offsets_seconds) if input_offsets_seconds else 0.0
delays_ms = [max(0, int(round((o - base) * 1000))) for o in input_offsets_seconds]
else:
delays_ms = [0 for _ in track_paths]
# --- Build filter graph ---
# N abuffer -> optional adelay -> amix -> aformat -> abuffersink
graph = av.filter.Graph()
inputs = []
for idx in range(len(track_paths)):
args = (
f"time_base=1/{target_sample_rate}:"
f"sample_rate={target_sample_rate}:"
f"sample_fmt=s32:"
f"channel_layout=stereo"
)
in_ctx = graph.add("abuffer", args=args, name=f"in{idx}")
inputs.append(in_ctx)
mixer = graph.add("amix", args=f"inputs={len(inputs)}:normalize=0", name="mix")
fmt = graph.add(
"aformat",
args=f"sample_fmts=s32:channel_layouts=stereo:sample_rates={target_sample_rate}",
name="fmt",
)
sink = graph.add("abuffersink", name="out")
for idx, in_ctx in enumerate(inputs):
delay_ms = delays_ms[idx] if idx < len(delays_ms) else 0
if delay_ms > 0:
adelay = graph.add(
"adelay",
args=f"delays={delay_ms}|{delay_ms}:all=1",
name=f"delay{idx}",
)
in_ctx.link_to(adelay)
adelay.link_to(mixer, 0, idx)
else:
in_ctx.link_to(mixer, 0, idx)
mixer.link_to(fmt)
fmt.link_to(sink)
graph.configure()
# --- Open all containers and decode ---
containers = []
output_path = os.path.join(temp_dir, "mixed.mp3")
try:
for path in track_paths:
containers.append(av.open(path))
decoders = [c.decode(audio=0) for c in containers]
active = [True] * len(decoders)
resamplers = [
AudioResampler(format="s32", layout="stereo", rate=target_sample_rate)
for _ in decoders
]
# Open output MP3
out_container = av.open(output_path, "w", format="mp3")
out_stream = out_container.add_stream("libmp3lame", rate=target_sample_rate)
total_duration = 0
while any(active):
for i, (dec, is_active) in enumerate(zip(decoders, active)):
if not is_active:
continue
try:
frame = next(dec)
except StopIteration:
active[i] = False
inputs[i].push(None)
continue
if frame.sample_rate != target_sample_rate:
continue
out_frames = resamplers[i].resample(frame) or []
for rf in out_frames:
rf.sample_rate = target_sample_rate
rf.time_base = Fraction(1, target_sample_rate)
inputs[i].push(rf)
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
for packet in out_stream.encode(mixed):
out_container.mux(packet)
total_duration += packet.duration
# Flush filter graph
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
for packet in out_stream.encode(mixed):
out_container.mux(packet)
total_duration += packet.duration
# Flush encoder
for packet in out_stream.encode(None):
out_container.mux(packet)
total_duration += packet.duration
# Calculate duration in ms
last_tb = out_stream.time_base
duration_ms = 0.0
if last_tb and total_duration > 0:
duration_ms = round(float(total_duration * last_tb * 1000), 2)
out_container.close()
finally:
for c in containers:
try:
c.close()
except Exception:
pass
file_size = os.path.getsize(output_path)
logger.info("Mixdown complete: %d bytes, %.2fms", file_size, duration_ms)
# --- Upload result ---
logger.info("Uploading mixed audio to S3")
with open(output_path, "rb") as f:
upload_response = requests.put(req.output_url, data=f, timeout=S3_TIMEOUT)
upload_response.raise_for_status()
logger.info("Upload complete: %d bytes", file_size)
return MixdownResponse(size=file_size, duration_ms=duration_ms)
except HTTPException:
raise
except Exception as e:
logger.error("Mixdown failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Mixdown failed: {e}") from e
finally:
for path in track_paths:
if os.path.exists(path):
try:
os.unlink(path)
except Exception as e:
logger.warning("Failed to cleanup track file: %s", e)
if output_path and os.path.exists(output_path):
try:
os.unlink(output_path)
except Exception as e:
logger.warning("Failed to cleanup output file: %s", e)
try:
os.rmdir(temp_dir)
except Exception as e:
logger.warning("Failed to cleanup temp directory: %s", e)

View File

@@ -0,0 +1,23 @@
#!/bin/sh
set -e
# Custom CA certificate injection
# If a CA cert is mounted at this path (via docker-compose.ca.yml),
# add it to the system trust store and configure all Python SSL libraries.
CUSTOM_CA_PATH="/usr/local/share/ca-certificates/custom-ca.crt"
if [ -s "$CUSTOM_CA_PATH" ]; then
echo "[entrypoint] Custom CA certificate detected, updating trust store..."
update-ca-certificates 2>/dev/null
# update-ca-certificates creates a combined bundle (system + custom CAs)
COMBINED_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
export SSL_CERT_FILE="$COMBINED_BUNDLE"
export REQUESTS_CA_BUNDLE="$COMBINED_BUNDLE"
export CURL_CA_BUNDLE="$COMBINED_BUNDLE"
# Note: GRPC_DEFAULT_SSL_ROOTS_FILE_PATH is intentionally NOT set here.
# Setting it causes grpcio to attempt TLS on connections that may be plaintext.
echo "[entrypoint] CA trust store updated (SSL_CERT_FILE=$COMBINED_BUNDLE)"
fi
exec sh /app/runserver.sh

View File

@@ -2153,7 +2153,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -2161,9 +2161,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
]
[[package]]

130
scripts/generate-certs.sh Executable file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
#
# Generate a local CA and server certificate for Reflector self-hosted deployments.
#
# Usage:
# ./scripts/generate-certs.sh DOMAIN [EXTRA_SANS...]
#
# Examples:
# ./scripts/generate-certs.sh reflector.local
# ./scripts/generate-certs.sh reflector.local "DNS:gpu.local,IP:192.168.1.100"
#
# Generates in certs/:
# ca.key — CA private key (keep secret)
# ca.crt — CA certificate (distribute to clients)
# server-key.pem — Server private key
# server.pem — Server certificate (signed by CA)
#
# Then use with setup-selfhosted.sh:
# ./scripts/setup-selfhosted.sh --gpu --caddy --domain DOMAIN --custom-ca certs/
#
set -euo pipefail
DOMAIN="${1:?Usage: $0 DOMAIN [EXTRA_SANS...]}"
EXTRA_SANS="${2:-}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CERTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/certs"
# Colors
GREEN='\033[0;32m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${CYAN}==>${NC} $*"; }
ok() { echo -e "${GREEN}${NC} $*"; }
# Check for openssl
if ! command -v openssl &>/dev/null; then
echo "Error: openssl is required but not found. Install it first." >&2
exit 1
fi
mkdir -p "$CERTS_DIR"
# Build SAN list
SAN_LIST="DNS:$DOMAIN,DNS:localhost,IP:127.0.0.1"
if [[ -n "$EXTRA_SANS" ]]; then
SAN_LIST="$SAN_LIST,$EXTRA_SANS"
fi
info "Generating CA and server certificate for: $DOMAIN"
echo " SANs: $SAN_LIST"
echo ""
# --- Step 1: Generate CA ---
if [[ -f "$CERTS_DIR/ca.key" ]] && [[ -f "$CERTS_DIR/ca.crt" ]]; then
ok "CA already exists at certs/ca.key + certs/ca.crt — reusing"
else
info "Generating CA key and certificate..."
openssl genrsa -out "$CERTS_DIR/ca.key" 4096 2>/dev/null
openssl req -x509 -new -nodes \
-key "$CERTS_DIR/ca.key" \
-sha256 -days 3650 \
-out "$CERTS_DIR/ca.crt" \
-subj "/CN=Reflector Local CA/O=Reflector Self-Hosted"
ok "CA certificate generated (valid for 10 years)"
fi
# --- Step 2: Generate server key ---
info "Generating server key..."
openssl genrsa -out "$CERTS_DIR/server-key.pem" 2048 2>/dev/null
ok "Server key generated"
# --- Step 3: Create CSR with SANs ---
info "Creating certificate signing request..."
openssl req -new \
-key "$CERTS_DIR/server-key.pem" \
-out "$CERTS_DIR/server.csr" \
-subj "/CN=$DOMAIN" \
-addext "subjectAltName=$SAN_LIST"
ok "CSR created"
# --- Step 4: Sign with CA ---
info "Signing server certificate with CA..."
openssl x509 -req \
-in "$CERTS_DIR/server.csr" \
-CA "$CERTS_DIR/ca.crt" \
-CAkey "$CERTS_DIR/ca.key" \
-CAcreateserial \
-out "$CERTS_DIR/server.pem" \
-days 365 -sha256 \
-copy_extensions copyall \
2>/dev/null
ok "Server certificate signed (valid for 1 year)"
# --- Cleanup ---
rm -f "$CERTS_DIR/server.csr" "$CERTS_DIR/ca.srl"
# --- Set permissions ---
chmod 644 "$CERTS_DIR/ca.crt" "$CERTS_DIR/server.pem"
chmod 600 "$CERTS_DIR/ca.key" "$CERTS_DIR/server-key.pem"
echo ""
echo "=========================================="
echo -e " ${GREEN}Certificates generated in certs/${NC}"
echo "=========================================="
echo ""
echo " certs/ca.key CA private key (keep secret)"
echo " certs/ca.crt CA certificate (distribute to clients)"
echo " certs/server-key.pem Server private key"
echo " certs/server.pem Server certificate for $DOMAIN"
echo ""
echo " SANs: $SAN_LIST"
echo ""
echo "Use with setup-selfhosted.sh:"
echo " ./scripts/setup-selfhosted.sh --gpu --caddy --domain $DOMAIN --custom-ca certs/"
echo ""
echo "Trust the CA on your machine:"
case "$(uname -s)" in
Darwin)
echo " sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certs/ca.crt"
;;
Linux)
echo " sudo cp certs/ca.crt /usr/local/share/ca-certificates/reflector-ca.crt"
echo " sudo update-ca-certificates"
;;
*)
echo " See docsv2/custom-ca-setup.md for your platform"
;;
esac
echo ""

167
scripts/run-integration-tests.sh Executable file
View File

@@ -0,0 +1,167 @@
#!/usr/bin/env bash
#
# Run integration tests locally.
#
# Spins up the full stack via Docker Compose, runs the three integration tests,
# and tears everything down afterward.
#
# Required environment variables:
# LLM_URL — OpenAI-compatible LLM endpoint (e.g. https://api.openai.com/v1)
# LLM_API_KEY — API key for the LLM endpoint
# HF_TOKEN — HuggingFace token for pyannote gated models
#
# Optional:
# LLM_MODEL — Model name (default: qwen2.5:14b)
#
# Flags:
# --build — Rebuild backend Docker images (server, workers, test-runner)
#
# Usage:
# export LLM_URL="https://api.openai.com/v1"
# export LLM_API_KEY="sk-..."
# export HF_TOKEN="hf_..."
# ./scripts/run-integration-tests.sh
# ./scripts/run-integration-tests.sh --build # rebuild backend images
#
set -euo pipefail
BUILD_FLAG=""
for arg in "$@"; do
case "$arg" in
--build) BUILD_FLAG="--build" ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_DIR="$REPO_ROOT/server/tests"
COMPOSE_FILE="$COMPOSE_DIR/docker-compose.integration.yml"
COMPOSE="docker compose -f $COMPOSE_FILE"
# ── Validate required env vars ──────────────────────────────────────────────
for var in LLM_URL LLM_API_KEY HF_TOKEN; do
if [[ -z "${!var:-}" ]]; then
echo "ERROR: $var is not set. See script header for required env vars."
exit 1
fi
done
export LLM_MODEL="${LLM_MODEL:-qwen2.5:14b}"
# ── Helpers ─────────────────────────────────────────────────────────────────
info() { echo -e "\n\033[1;34m▸ $*\033[0m"; }
ok() { echo -e "\033[1;32m ✓ $*\033[0m"; }
fail() { echo -e "\033[1;31m ✗ $*\033[0m"; }
wait_for() {
local desc="$1" cmd="$2" max="${3:-60}"
info "Waiting for $desc (up to ${max}s)..."
for i in $(seq 1 "$max"); do
if eval "$cmd" &>/dev/null; then
ok "$desc is ready"
return 0
fi
sleep 2
done
fail "$desc did not become ready within ${max}s"
return 1
}
cleanup() {
info "Tearing down..."
$COMPOSE down -v --remove-orphans 2>/dev/null || true
}
# Always tear down on exit
trap cleanup EXIT
# ── Step 1: Build and start infrastructure ──────────────────────────────────
info "Building and starting infrastructure services..."
$COMPOSE up -d --build postgres redis garage hatchet mock-daily mailpit
# ── Step 2: Set up Garage (S3 bucket + keys) ───────────────────────────────
wait_for "Garage" "$COMPOSE exec -T garage /garage stats" 60
info "Setting up Garage bucket and keys..."
GARAGE="$COMPOSE exec -T garage /garage"
# Hardcoded test credentials — ephemeral containers, destroyed after tests
export GARAGE_KEY_ID="GK0123456789abcdef01234567" # gitleaks:allow
export GARAGE_KEY_SECRET="0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # gitleaks:allow
# Layout
NODE_ID=$($GARAGE node id -q 2>&1 | tr -d '[:space:]')
LAYOUT_STATUS=$($GARAGE layout show 2>&1 || true)
if echo "$LAYOUT_STATUS" | grep -q "No nodes"; then
$GARAGE layout assign "$NODE_ID" -c 1G -z dc1
$GARAGE layout apply --version 1
fi
# Bucket
$GARAGE bucket info reflector-media >/dev/null 2>&1 || $GARAGE bucket create reflector-media
# Import key with known credentials
if ! $GARAGE key info reflector-test >/dev/null 2>&1; then
$GARAGE key import --yes "$GARAGE_KEY_ID" "$GARAGE_KEY_SECRET"
$GARAGE key rename "$GARAGE_KEY_ID" reflector-test
fi
# Permissions
$GARAGE bucket allow reflector-media --read --write --key reflector-test
ok "Garage ready with hardcoded test credentials"
# ── Step 3: Generate Hatchet API token ──────────────────────────────────────
wait_for "Hatchet" "$COMPOSE exec -T hatchet curl -sf http://localhost:8888/api/live" 90
info "Generating Hatchet API token..."
HATCHET_TOKEN_OUTPUT=$($COMPOSE exec -T hatchet /hatchet-admin token create --config /config --name local-test 2>&1)
export HATCHET_CLIENT_TOKEN=$(echo "$HATCHET_TOKEN_OUTPUT" | grep -o 'eyJ[A-Za-z0-9_.\-]*')
if [[ -z "$HATCHET_CLIENT_TOKEN" ]]; then
fail "Failed to extract Hatchet token (JWT not found in output)"
echo " Output was: $HATCHET_TOKEN_OUTPUT"
exit 1
fi
ok "Hatchet token generated"
# ── Step 4: Start backend services ──────────────────────────────────────────
info "Starting backend services..."
$COMPOSE up -d $BUILD_FLAG server worker hatchet-worker-cpu hatchet-worker-llm test-runner
# ── Step 5: Wait for server + run migrations ────────────────────────────────
wait_for "Server" "$COMPOSE exec -T test-runner curl -sf http://server:1250/health" 60
info "Running database migrations..."
$COMPOSE exec -T server uv run alembic upgrade head
ok "Migrations applied"
# ── Step 6: Run integration tests ───────────────────────────────────────────
info "Running integration tests..."
echo ""
LOGS_DIR="$COMPOSE_DIR/integration/logs"
mkdir -p "$LOGS_DIR"
RUN_TIMESTAMP=$(date +%Y%m%d-%H%M%S)
TEST_LOG="$LOGS_DIR/$RUN_TIMESTAMP.txt"
if $COMPOSE exec -T test-runner uv run pytest tests/integration/ -v -x 2>&1 | tee "$TEST_LOG.pytest"; then
echo ""
ok "All integration tests passed!"
EXIT_CODE=0
else
echo ""
fail "Integration tests failed!"
EXIT_CODE=1
fi
# Always collect service logs + test output into a single file
info "Collecting logs..."
$COMPOSE logs --tail=500 > "$TEST_LOG" 2>&1
echo -e "\n\n=== PYTEST OUTPUT ===\n" >> "$TEST_LOG"
cat "$TEST_LOG.pytest" >> "$TEST_LOG" 2>/dev/null
rm -f "$TEST_LOG.pytest"
echo " Logs saved to: server/tests/integration/logs/$RUN_TIMESTAMP.txt"
# cleanup runs via trap
exit $EXIT_CODE

496
scripts/setup-gpu-host.sh Executable file
View File

@@ -0,0 +1,496 @@
#!/usr/bin/env bash
#
# Standalone GPU service setup for Reflector.
# Deploys ONLY the GPU transcription/diarization/translation service on a dedicated machine.
# The main Reflector instance connects to this machine over HTTPS.
#
# Usage:
# ./scripts/setup-gpu-host.sh [--domain DOMAIN] [--custom-ca PATH] [--extra-ca FILE] [--api-key KEY] [--cpu] [--build]
#
# Options:
# --domain DOMAIN Domain name for this GPU host (e.g., gpu.example.com)
# With --custom-ca: uses custom TLS cert. Without: uses Let's Encrypt.
# --custom-ca PATH Custom CA certificate (dir with ca.crt + server.pem + server-key.pem, or single PEM file)
# --extra-ca FILE Additional CA cert to trust (repeatable)
# --api-key KEY API key to protect the GPU service (recommended for internet-facing deployments)
# --cpu Use CPU-only Dockerfile (no NVIDIA GPU required)
# --build Build image from source (default: build, since no pre-built GPU image is published)
# --port PORT Host port to expose (default: 443 with Caddy, 8000 without)
#
# Examples:
# # GPU on LAN with custom CA
# ./scripts/generate-certs.sh gpu.local
# ./scripts/setup-gpu-host.sh --domain gpu.local --custom-ca certs/ --api-key my-secret-key
#
# # GPU on public internet with Let's Encrypt
# ./scripts/setup-gpu-host.sh --domain gpu.example.com --api-key my-secret-key
#
# # GPU on LAN, IP access only (self-signed cert)
# ./scripts/setup-gpu-host.sh --api-key my-secret-key
#
# # CPU-only mode (no NVIDIA GPU)
# ./scripts/setup-gpu-host.sh --cpu --api-key my-secret-key
#
# After setup, configure the main Reflector instance to use this GPU:
# In server/.env on the Reflector machine:
# TRANSCRIPT_BACKEND=modal
# TRANSCRIPT_URL=https://gpu.example.com
# TRANSCRIPT_MODAL_API_KEY=my-secret-key
# DIARIZATION_BACKEND=modal
# DIARIZATION_URL=https://gpu.example.com
# DIARIZATION_MODAL_API_KEY=my-secret-key
# TRANSLATION_BACKEND=modal
# TRANSLATE_URL=https://gpu.example.com
# TRANSLATION_MODAL_API_KEY=my-secret-key
#
# DNS Resolution:
# - Public domain: Create a DNS A record pointing to this machine's public IP.
# - Internal domain (e.g., gpu.local): Add to /etc/hosts on both machines:
# <GPU_MACHINE_IP> gpu.local
# - IP-only: Use the machine's IP directly in TRANSCRIPT_URL/DIARIZATION_URL.
# The Reflector backend must trust the CA or accept self-signed certs.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GPU_DIR="$ROOT_DIR/gpu/self_hosted"
OS="$(uname -s)"
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${CYAN}==>${NC} $*"; }
ok() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW} !${NC} $*"; }
err() { echo -e "${RED}${NC} $*" >&2; }
# --- Parse arguments ---
CUSTOM_DOMAIN=""
CUSTOM_CA=""
EXTRA_CA_FILES=()
API_KEY=""
USE_CPU=false
HOST_PORT=""
SKIP_NEXT=false
ARGS=("$@")
for i in "${!ARGS[@]}"; do
if [[ "$SKIP_NEXT" == "true" ]]; then
SKIP_NEXT=false
continue
fi
arg="${ARGS[$i]}"
case "$arg" in
--domain)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--domain requires a domain name"
exit 1
fi
CUSTOM_DOMAIN="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
--custom-ca)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--custom-ca requires a path to a directory or PEM certificate file"
exit 1
fi
CUSTOM_CA="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
--extra-ca)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--extra-ca requires a path to a PEM certificate file"
exit 1
fi
if [[ ! -f "${ARGS[$next_i]}" ]]; then
err "--extra-ca file not found: ${ARGS[$next_i]}"
exit 1
fi
EXTRA_CA_FILES+=("${ARGS[$next_i]}")
SKIP_NEXT=true ;;
--api-key)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--api-key requires a key value"
exit 1
fi
API_KEY="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
--cpu)
USE_CPU=true ;;
--port)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--port requires a port number"
exit 1
fi
HOST_PORT="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
--build)
;; # Always build from source for GPU, flag accepted for compatibility
*)
err "Unknown argument: $arg"
err "Usage: $0 [--domain DOMAIN] [--custom-ca PATH] [--extra-ca FILE] [--api-key KEY] [--cpu] [--port PORT]"
exit 1
;;
esac
done
# --- Resolve CA paths ---
CA_CERT_PATH=""
TLS_CERT_PATH=""
TLS_KEY_PATH=""
USE_CUSTOM_CA=false
USE_CADDY=false
if [[ -n "$CUSTOM_CA" ]] || [[ -n "${EXTRA_CA_FILES[0]+x}" ]]; then
USE_CUSTOM_CA=true
fi
if [[ -n "$CUSTOM_CA" ]]; then
CUSTOM_CA="${CUSTOM_CA%/}"
if [[ -d "$CUSTOM_CA" ]]; then
[[ -f "$CUSTOM_CA/ca.crt" ]] || { err "$CUSTOM_CA/ca.crt not found"; exit 1; }
CA_CERT_PATH="$CUSTOM_CA/ca.crt"
if [[ -f "$CUSTOM_CA/server.pem" ]] && [[ -f "$CUSTOM_CA/server-key.pem" ]]; then
TLS_CERT_PATH="$CUSTOM_CA/server.pem"
TLS_KEY_PATH="$CUSTOM_CA/server-key.pem"
elif [[ -f "$CUSTOM_CA/server.pem" ]] || [[ -f "$CUSTOM_CA/server-key.pem" ]]; then
warn "Found only one of server.pem/server-key.pem — both needed for TLS. Skipping."
fi
elif [[ -f "$CUSTOM_CA" ]]; then
CA_CERT_PATH="$CUSTOM_CA"
else
err "--custom-ca path not found: $CUSTOM_CA"
exit 1
fi
elif [[ -n "${EXTRA_CA_FILES[0]+x}" ]]; then
CA_CERT_PATH="${EXTRA_CA_FILES[0]}"
unset 'EXTRA_CA_FILES[0]'
EXTRA_CA_FILES=("${EXTRA_CA_FILES[@]+"${EXTRA_CA_FILES[@]}"}")
fi
# Caddy if we have a domain or TLS certs
if [[ -n "$CUSTOM_DOMAIN" ]] || [[ -n "$TLS_CERT_PATH" ]]; then
USE_CADDY=true
fi
# Default port
if [[ -z "$HOST_PORT" ]]; then
if [[ "$USE_CADDY" == "true" ]]; then
HOST_PORT="443"
else
HOST_PORT="8000"
fi
fi
# Detect primary IP
PRIMARY_IP=""
if [[ "$OS" == "Linux" ]]; then
PRIMARY_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
if [[ "$PRIMARY_IP" == "127."* ]] || [[ -z "$PRIMARY_IP" ]]; then
PRIMARY_IP=$(ip -4 route get 1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' || true)
fi
fi
# --- Display config ---
echo ""
echo "=========================================="
echo " Reflector — Standalone GPU Host Setup"
echo "=========================================="
echo ""
echo " Mode: $(if [[ "$USE_CPU" == "true" ]]; then echo "CPU-only"; else echo "NVIDIA GPU"; fi)"
echo " Caddy: $USE_CADDY"
[[ -n "$CUSTOM_DOMAIN" ]] && echo " Domain: $CUSTOM_DOMAIN"
[[ "$USE_CUSTOM_CA" == "true" ]] && echo " CA: Custom"
[[ -n "$TLS_CERT_PATH" ]] && echo " TLS: Custom cert"
[[ -n "$API_KEY" ]] && echo " Auth: API key protected"
[[ -z "$API_KEY" ]] && echo " Auth: NONE (open access — use --api-key for production!)"
echo " Port: $HOST_PORT"
echo ""
# --- Prerequisites ---
info "Checking prerequisites"
if ! command -v docker &>/dev/null; then
err "Docker not found. Install Docker first."
exit 1
fi
ok "Docker available"
if ! docker compose version &>/dev/null; then
err "Docker Compose V2 not found."
exit 1
fi
ok "Docker Compose V2 available"
if [[ "$USE_CPU" != "true" ]]; then
if ! docker info 2>/dev/null | grep -qi nvidia; then
warn "NVIDIA runtime not detected in Docker. GPU mode may fail."
warn "Install nvidia-container-toolkit if you have an NVIDIA GPU."
else
ok "NVIDIA Docker runtime available"
fi
fi
# --- Stage certificates ---
CERTS_DIR="$ROOT_DIR/certs"
if [[ "$USE_CUSTOM_CA" == "true" ]]; then
info "Staging certificates"
mkdir -p "$CERTS_DIR"
if [[ -n "$CA_CERT_PATH" ]]; then
local_ca_dest="$CERTS_DIR/ca.crt"
src_id=$(ls -i "$CA_CERT_PATH" 2>/dev/null | awk '{print $1}')
dst_id=$(ls -i "$local_ca_dest" 2>/dev/null | awk '{print $1}')
if [[ "$src_id" != "$dst_id" ]] || [[ -z "$dst_id" ]]; then
cp "$CA_CERT_PATH" "$local_ca_dest"
fi
chmod 644 "$local_ca_dest"
ok "CA certificate staged"
# Append extra CAs
for extra_ca in "${EXTRA_CA_FILES[@]+"${EXTRA_CA_FILES[@]}"}"; do
echo "" >> "$local_ca_dest"
cat "$extra_ca" >> "$local_ca_dest"
ok "Appended extra CA: $extra_ca"
done
fi
if [[ -n "$TLS_CERT_PATH" ]]; then
cert_dest="$CERTS_DIR/server.pem"
key_dest="$CERTS_DIR/server-key.pem"
src_id=$(ls -i "$TLS_CERT_PATH" 2>/dev/null | awk '{print $1}')
dst_id=$(ls -i "$cert_dest" 2>/dev/null | awk '{print $1}')
if [[ "$src_id" != "$dst_id" ]] || [[ -z "$dst_id" ]]; then
cp "$TLS_CERT_PATH" "$cert_dest"
cp "$TLS_KEY_PATH" "$key_dest"
fi
chmod 644 "$cert_dest"
chmod 600 "$key_dest"
ok "TLS cert/key staged"
fi
fi
# --- Build profiles and compose command ---
COMPOSE_FILE="$ROOT_DIR/docker-compose.gpu-host.yml"
COMPOSE_PROFILES=()
GPU_SERVICE="gpu"
if [[ "$USE_CPU" == "true" ]]; then
COMPOSE_PROFILES+=("cpu")
GPU_SERVICE="cpu"
else
COMPOSE_PROFILES+=("gpu")
fi
if [[ "$USE_CADDY" == "true" ]]; then
COMPOSE_PROFILES+=("caddy")
fi
# Compose command helper
compose_cmd() {
local profiles="" files="-f $COMPOSE_FILE"
if [[ "$USE_CUSTOM_CA" == "true" ]] && [[ -f "$ROOT_DIR/docker-compose.gpu-ca.yml" ]]; then
files="$files -f $ROOT_DIR/docker-compose.gpu-ca.yml"
fi
for p in "${COMPOSE_PROFILES[@]}"; do
profiles="$profiles --profile $p"
done
docker compose $files $profiles "$@"
}
# Generate CA compose override if needed (mounts certs into containers)
if [[ "$USE_CUSTOM_CA" == "true" ]]; then
info "Generating docker-compose.gpu-ca.yml override"
ca_override="$ROOT_DIR/docker-compose.gpu-ca.yml"
cat > "$ca_override" << 'CAEOF'
# Generated by setup-gpu-host.sh — custom CA trust.
# Do not edit manually; re-run setup-gpu-host.sh with --custom-ca to regenerate.
services:
gpu:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
cpu:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
CAEOF
if [[ -n "$TLS_CERT_PATH" ]]; then
cat >> "$ca_override" << 'CADDYCAEOF'
caddy:
volumes:
- ./certs:/etc/caddy/certs:ro
CADDYCAEOF
fi
ok "Generated docker-compose.gpu-ca.yml"
else
rm -f "$ROOT_DIR/docker-compose.gpu-ca.yml"
fi
# --- Generate Caddyfile ---
if [[ "$USE_CADDY" == "true" ]]; then
info "Generating Caddyfile.gpu-host"
CADDYFILE="$ROOT_DIR/Caddyfile.gpu-host"
if [[ -n "$TLS_CERT_PATH" ]] && [[ -n "$CUSTOM_DOMAIN" ]]; then
cat > "$CADDYFILE" << CADDYEOF
# Generated by setup-gpu-host.sh — Custom TLS cert for $CUSTOM_DOMAIN
$CUSTOM_DOMAIN {
tls /etc/caddy/certs/server.pem /etc/caddy/certs/server-key.pem
reverse_proxy transcription:8000
}
CADDYEOF
ok "Caddyfile: custom TLS for $CUSTOM_DOMAIN"
elif [[ -n "$CUSTOM_DOMAIN" ]]; then
cat > "$CADDYFILE" << CADDYEOF
# Generated by setup-gpu-host.sh — Let's Encrypt for $CUSTOM_DOMAIN
$CUSTOM_DOMAIN {
reverse_proxy transcription:8000
}
CADDYEOF
ok "Caddyfile: Let's Encrypt for $CUSTOM_DOMAIN"
else
cat > "$CADDYFILE" << 'CADDYEOF'
# Generated by setup-gpu-host.sh — self-signed cert for IP access
:443 {
tls internal
reverse_proxy transcription:8000
}
CADDYEOF
ok "Caddyfile: self-signed cert for IP access"
fi
fi
# --- Generate .env ---
info "Generating GPU service .env"
GPU_ENV="$ROOT_DIR/.env.gpu-host"
cat > "$GPU_ENV" << EOF
# Generated by setup-gpu-host.sh
# HuggingFace token for pyannote diarization models
HF_TOKEN=${HF_TOKEN:-}
# API key to protect the GPU service (set via --api-key)
REFLECTOR_GPU_APIKEY=${API_KEY:-}
# Port configuration
GPU_HOST_PORT=${HOST_PORT}
CADDY_HTTPS_PORT=${HOST_PORT}
EOF
if [[ -z "${HF_TOKEN:-}" ]]; then
warn "HF_TOKEN not set. Diarization requires a HuggingFace token."
warn "Set it: export HF_TOKEN=your-token-here and re-run, or edit .env.gpu-host"
fi
ok "Generated .env.gpu-host"
# --- Build and start ---
info "Building $GPU_SERVICE image (first build downloads ML models — may take a while)..."
compose_cmd --env-file "$GPU_ENV" build "$GPU_SERVICE"
ok "$GPU_SERVICE image built"
info "Starting services..."
compose_cmd --env-file "$GPU_ENV" up -d
ok "Services started"
# --- Wait for health ---
info "Waiting for GPU service to be healthy (model loading takes 1-2 minutes)..."
local_url="http://localhost:8000"
for i in $(seq 1 40); do
if curl -sf "$local_url/docs" >/dev/null 2>&1; then
ok "GPU service is healthy!"
break
fi
if [[ $i -eq 40 ]]; then
err "GPU service did not become healthy after 5 minutes."
err "Check logs: docker compose -f docker-compose.gpu-host.yml logs gpu"
exit 1
fi
sleep 8
done
# --- Summary ---
echo ""
echo "=========================================="
echo -e " ${GREEN}GPU service is running!${NC}"
echo "=========================================="
echo ""
if [[ "$USE_CADDY" == "true" ]]; then
if [[ -n "$CUSTOM_DOMAIN" ]]; then
echo " URL: https://$CUSTOM_DOMAIN"
elif [[ -n "$PRIMARY_IP" ]]; then
echo " URL: https://$PRIMARY_IP"
else
echo " URL: https://localhost"
fi
else
if [[ -n "$PRIMARY_IP" ]]; then
echo " URL: http://$PRIMARY_IP:$HOST_PORT"
else
echo " URL: http://localhost:$HOST_PORT"
fi
fi
echo " Health: curl \$(URL)/docs"
[[ -n "$API_KEY" ]] && echo " API key: $API_KEY"
echo ""
echo " Configure the main Reflector instance (in server/.env):"
echo ""
local_gpu_url=""
if [[ "$USE_CADDY" == "true" ]]; then
if [[ -n "$CUSTOM_DOMAIN" ]]; then
local_gpu_url="https://$CUSTOM_DOMAIN"
elif [[ -n "$PRIMARY_IP" ]]; then
local_gpu_url="https://$PRIMARY_IP"
else
local_gpu_url="https://localhost"
fi
else
if [[ -n "$PRIMARY_IP" ]]; then
local_gpu_url="http://$PRIMARY_IP:$HOST_PORT"
else
local_gpu_url="http://localhost:$HOST_PORT"
fi
fi
echo " TRANSCRIPT_BACKEND=modal"
echo " TRANSCRIPT_URL=$local_gpu_url"
[[ -n "$API_KEY" ]] && echo " TRANSCRIPT_MODAL_API_KEY=$API_KEY"
echo " DIARIZATION_BACKEND=modal"
echo " DIARIZATION_URL=$local_gpu_url"
[[ -n "$API_KEY" ]] && echo " DIARIZATION_MODAL_API_KEY=$API_KEY"
echo " TRANSLATION_BACKEND=modal"
echo " TRANSLATE_URL=$local_gpu_url"
[[ -n "$API_KEY" ]] && echo " TRANSLATION_MODAL_API_KEY=$API_KEY"
echo ""
if [[ "$USE_CUSTOM_CA" == "true" ]]; then
echo " The Reflector instance must also trust this CA."
echo " On the Reflector machine, run setup-selfhosted.sh with:"
echo " --extra-ca /path/to/this-machines-ca.crt"
echo ""
fi
echo " DNS Resolution:"
if [[ -n "$CUSTOM_DOMAIN" ]]; then
echo " Ensure '$CUSTOM_DOMAIN' resolves to this machine's IP."
echo " Public: Create a DNS A record."
echo " Internal: Add to /etc/hosts on the Reflector machine:"
echo " ${PRIMARY_IP:-<GPU_IP>} $CUSTOM_DOMAIN"
else
echo " Use this machine's IP directly in TRANSCRIPT_URL/DIARIZATION_URL."
fi
echo ""
echo " To stop: docker compose -f docker-compose.gpu-host.yml down"
echo " To re-run: ./scripts/setup-gpu-host.sh $*"
echo " Logs: docker compose -f docker-compose.gpu-host.yml logs -f gpu"
echo ""

View File

@@ -4,13 +4,21 @@
# Single script to configure and launch everything on one server.
#
# Usage:
# ./scripts/setup-selfhosted.sh <--gpu|--cpu|--hosted> [--ollama-gpu|--ollama-cpu] [--llm-model MODEL] [--garage] [--caddy] [--domain DOMAIN] [--password PASSWORD] [--build]
# ./scripts/setup-selfhosted.sh <--gpu|--cpu|--hosted> [options] [--transcript BACKEND] [--diarization BACKEND] [--translation BACKEND] [--padding BACKEND] [--mixdown BACKEND]
# ./scripts/setup-selfhosted.sh (re-run with saved config from last run)
#
# ML processing modes (pick ONE — required):
# ML processing modes (pick ONE — required on first run):
# --gpu NVIDIA GPU container for transcription/diarization/translation
# --cpu In-process CPU processing (no ML container, slower)
# --hosted Remote GPU service URL (no ML container)
#
# Per-service backend overrides (optional — override individual services from the base mode):
# --transcript BACKEND whisper | modal (default: whisper for --cpu, modal for --gpu/--hosted)
# --diarization BACKEND pyannote | modal (default: pyannote for --cpu, modal for --gpu/--hosted)
# --translation BACKEND marian | modal | passthrough (default: marian for --cpu, modal for --gpu/--hosted)
# --padding BACKEND pyav | modal (default: pyav for --cpu, modal for --gpu/--hosted)
# --mixdown BACKEND pyav | modal (default: pyav for --cpu, modal for --gpu/--hosted)
#
# Local LLM (optional — for summarization & topic detection):
# --ollama-gpu Local Ollama with NVIDIA GPU acceleration
# --ollama-cpu Local Ollama on CPU only
@@ -23,6 +31,13 @@
# --domain DOMAIN Use a real domain for Caddy (enables Let's Encrypt auto-HTTPS)
# Requires: DNS pointing to this server + ports 80/443 open
# Without --domain: Caddy uses self-signed cert for IP access
# --custom-ca PATH Custom CA certificate for private HTTPS services
# PATH can be a directory (containing ca.crt, optionally server.pem + server-key.pem)
# or a single PEM file (CA trust only, no Caddy TLS)
# With server.pem+server-key.pem: Caddy serves HTTPS using those certs (requires --domain)
# Without: only injects CA trust into backend containers for outbound calls
# --extra-ca FILE Additional CA cert to trust (can be repeated for multiple CAs)
# Appended to the CA bundle so backends trust multiple authorities
# --password PASS Enable password auth with admin@localhost user
# --build Build backend and frontend images from source instead of pulling
#
@@ -31,10 +46,17 @@
# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy --domain reflector.example.com
# ./scripts/setup-selfhosted.sh --cpu --ollama-cpu --garage --caddy
# ./scripts/setup-selfhosted.sh --hosted --garage --caddy
# ./scripts/setup-selfhosted.sh --cpu --padding modal --garage --caddy
# ./scripts/setup-selfhosted.sh --gpu --translation passthrough --garage --caddy
# ./scripts/setup-selfhosted.sh --cpu --diarization modal --translation modal --garage
# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --llm-model mistral --garage --caddy
# ./scripts/setup-selfhosted.sh --gpu --garage --caddy --password mysecretpass
# ./scripts/setup-selfhosted.sh --gpu --garage --caddy
# ./scripts/setup-selfhosted.sh --cpu
# ./scripts/setup-selfhosted.sh --gpu --caddy --domain reflector.local --custom-ca certs/
# ./scripts/setup-selfhosted.sh --hosted --custom-ca /path/to/corporate-ca.crt
# ./scripts/setup-selfhosted.sh # re-run with saved config
#
# Config memory: after a successful run, flags are saved to data/.selfhosted-last-args.
# Re-running with no arguments replays the saved configuration automatically.
#
# The script auto-detects Daily.co (DAILY_API_KEY) and Whereby (WHEREBY_API_KEY)
# from server/.env. If Daily.co is configured, Hatchet workflow services are
@@ -50,6 +72,7 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker-compose.selfhosted.yml"
SERVER_ENV="$ROOT_DIR/server/.env"
WWW_ENV="$ROOT_DIR/www/.env"
LAST_ARGS_FILE="$ROOT_DIR/data/.selfhosted-last-args"
OLLAMA_MODEL="qwen2.5:14b"
OS="$(uname -s)"
@@ -154,18 +177,32 @@ env_set() {
}
compose_cmd() {
local profiles=""
local profiles="" files="-f $COMPOSE_FILE"
[[ "$USE_CUSTOM_CA" == "true" ]] && files="$files -f $ROOT_DIR/docker-compose.ca.yml"
for p in "${COMPOSE_PROFILES[@]}"; do
profiles="$profiles --profile $p"
done
docker compose -f "$COMPOSE_FILE" $profiles "$@"
docker compose $files $profiles "$@"
}
# Compose command with only garage profile (for garage-only operations before full stack start)
compose_garage_cmd() {
docker compose -f "$COMPOSE_FILE" --profile garage "$@"
local files="-f $COMPOSE_FILE"
[[ "$USE_CUSTOM_CA" == "true" ]] && files="$files -f $ROOT_DIR/docker-compose.ca.yml"
docker compose $files --profile garage "$@"
}
# --- Config memory: replay last args if none provided ---
if [[ $# -eq 0 ]] && [[ -f "$LAST_ARGS_FILE" ]]; then
SAVED_ARGS="$(cat "$LAST_ARGS_FILE")"
if [[ -n "$SAVED_ARGS" ]]; then
info "No flags provided — replaying saved configuration:"
info " $SAVED_ARGS"
echo ""
eval "set -- $SAVED_ARGS"
fi
fi
# --- Parse arguments ---
MODEL_MODE="" # gpu or cpu (required, mutually exclusive)
OLLAMA_MODE="" # ollama-gpu or ollama-cpu (optional)
@@ -174,6 +211,22 @@ USE_CADDY=false
CUSTOM_DOMAIN="" # optional domain for Let's Encrypt HTTPS
BUILD_IMAGES=false # build backend/frontend from source
ADMIN_PASSWORD="" # optional admin password for password auth
CUSTOM_CA="" # --custom-ca: path to dir or CA cert file
USE_CUSTOM_CA=false # derived flag: true when --custom-ca is provided
EXTRA_CA_FILES=() # --extra-ca: additional CA certs to trust (can be repeated)
OVERRIDE_TRANSCRIPT="" # per-service override: whisper | modal
OVERRIDE_DIARIZATION="" # per-service override: pyannote | modal
OVERRIDE_TRANSLATION="" # per-service override: marian | modal | passthrough
OVERRIDE_PADDING="" # per-service override: pyav | modal
OVERRIDE_MIXDOWN="" # per-service override: pyav | modal
# Validate per-service backend override values
validate_backend() {
local service="$1" value="$2"; shift 2; local valid=("$@")
for v in "${valid[@]}"; do [[ "$value" == "$v" ]] && return 0; done
err "--$service value '$value' is not valid. Choose one of: ${valid[*]}"
exit 1
}
SKIP_NEXT=false
ARGS=("$@")
@@ -227,24 +280,159 @@ for i in "${!ARGS[@]}"; do
CUSTOM_DOMAIN="${ARGS[$next_i]}"
USE_CADDY=true # --domain implies --caddy
SKIP_NEXT=true ;;
--custom-ca)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--custom-ca requires a path to a directory or PEM certificate file"
exit 1
fi
CUSTOM_CA="${ARGS[$next_i]}"
USE_CUSTOM_CA=true
SKIP_NEXT=true ;;
--extra-ca)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--extra-ca requires a path to a PEM certificate file"
exit 1
fi
extra_ca_file="${ARGS[$next_i]}"
if [[ ! -f "$extra_ca_file" ]]; then
err "--extra-ca file not found: $extra_ca_file"
exit 1
fi
EXTRA_CA_FILES+=("$extra_ca_file")
USE_CUSTOM_CA=true
SKIP_NEXT=true ;;
--transcript)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--transcript requires a backend (whisper | modal)"
exit 1
fi
validate_backend "transcript" "${ARGS[$next_i]}" whisper modal
OVERRIDE_TRANSCRIPT="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
--diarization)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--diarization requires a backend (pyannote | modal)"
exit 1
fi
validate_backend "diarization" "${ARGS[$next_i]}" pyannote modal
OVERRIDE_DIARIZATION="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
--translation)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--translation requires a backend (marian | modal | passthrough)"
exit 1
fi
validate_backend "translation" "${ARGS[$next_i]}" marian modal passthrough
OVERRIDE_TRANSLATION="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
--padding)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--padding requires a backend (pyav | modal)"
exit 1
fi
validate_backend "padding" "${ARGS[$next_i]}" pyav modal
OVERRIDE_PADDING="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
--mixdown)
next_i=$((i + 1))
if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then
err "--mixdown requires a backend (pyav | modal)"
exit 1
fi
validate_backend "mixdown" "${ARGS[$next_i]}" pyav modal
OVERRIDE_MIXDOWN="${ARGS[$next_i]}"
SKIP_NEXT=true ;;
*)
err "Unknown argument: $arg"
err "Usage: $0 <--gpu|--cpu|--hosted> [--ollama-gpu|--ollama-cpu] [--llm-model MODEL] [--garage] [--caddy] [--domain DOMAIN] [--password PASS] [--build]"
err "Usage: $0 <--gpu|--cpu|--hosted> [options] [--transcript BACKEND] [--diarization BACKEND] [--translation BACKEND] [--padding BACKEND] [--mixdown BACKEND]"
exit 1
;;
esac
done
# --- Save CLI args for config memory (re-run without flags) ---
if [[ $# -gt 0 ]]; then
mkdir -p "$ROOT_DIR/data"
printf '%q ' "$@" > "$LAST_ARGS_FILE"
fi
# --- Resolve --custom-ca flag ---
CA_CERT_PATH="" # resolved path to CA certificate
TLS_CERT_PATH="" # resolved path to server cert (optional, for Caddy TLS)
TLS_KEY_PATH="" # resolved path to server key (optional, for Caddy TLS)
if [[ "$USE_CUSTOM_CA" == "true" ]]; then
# Strip trailing slashes to avoid double-slash paths
CUSTOM_CA="${CUSTOM_CA%/}"
if [[ -z "$CUSTOM_CA" ]] && [[ -n "${EXTRA_CA_FILES[0]+x}" ]]; then
# --extra-ca only (no --custom-ca): use first extra CA as the base
CA_CERT_PATH="${EXTRA_CA_FILES[0]}"
unset 'EXTRA_CA_FILES[0]'
EXTRA_CA_FILES=("${EXTRA_CA_FILES[@]+"${EXTRA_CA_FILES[@]}"}")
elif [[ -d "$CUSTOM_CA" ]]; then
# Directory mode: look for convention files
if [[ ! -f "$CUSTOM_CA/ca.crt" ]]; then
err "CA certificate not found: $CUSTOM_CA/ca.crt"
err "Directory must contain ca.crt (and optionally server.pem + server-key.pem)"
exit 1
fi
CA_CERT_PATH="$CUSTOM_CA/ca.crt"
# Server cert/key are optional — if both present, use for Caddy TLS
if [[ -f "$CUSTOM_CA/server.pem" ]] && [[ -f "$CUSTOM_CA/server-key.pem" ]]; then
TLS_CERT_PATH="$CUSTOM_CA/server.pem"
TLS_KEY_PATH="$CUSTOM_CA/server-key.pem"
elif [[ -f "$CUSTOM_CA/server.pem" ]] || [[ -f "$CUSTOM_CA/server-key.pem" ]]; then
warn "Found only one of server.pem/server-key.pem in $CUSTOM_CA — both are needed for Caddy TLS. Skipping."
fi
elif [[ -f "$CUSTOM_CA" ]]; then
# Single file mode: CA trust only (no Caddy TLS certs)
CA_CERT_PATH="$CUSTOM_CA"
else
err "--custom-ca path not found: $CUSTOM_CA"
exit 1
fi
# Validate PEM format
if ! head -1 "$CA_CERT_PATH" | grep -q "BEGIN"; then
err "CA certificate does not appear to be PEM format: $CA_CERT_PATH"
exit 1
fi
# If server cert/key found, require --domain and imply --caddy
if [[ -n "$TLS_CERT_PATH" ]]; then
if [[ -z "$CUSTOM_DOMAIN" ]]; then
err "Server cert/key found in $CUSTOM_CA but --domain not set."
err "Provide --domain to specify the domain name matching the certificate."
exit 1
fi
USE_CADDY=true # custom TLS certs imply --caddy
fi
fi
if [[ -z "$MODEL_MODE" ]]; then
err "No model mode specified. You must choose --gpu, --cpu, or --hosted."
err ""
err "Usage: $0 <--gpu|--cpu|--hosted> [--ollama-gpu|--ollama-cpu] [--llm-model MODEL] [--garage] [--caddy] [--domain DOMAIN] [--password PASS] [--build]"
err "Usage: $0 <--gpu|--cpu|--hosted> [options] [--transcript BACKEND] [--diarization BACKEND] [--translation BACKEND] [--padding BACKEND] [--mixdown BACKEND]"
err ""
err "ML processing modes (required):"
err " --gpu NVIDIA GPU container for transcription/diarization/translation"
err " --cpu In-process CPU processing (no ML container, slower)"
err " --hosted Remote GPU service URL (no ML container)"
err ""
err "Per-service backend overrides (optional — override individual services):"
err " --transcript BACKEND whisper | modal (default: whisper for --cpu, modal for --gpu/--hosted)"
err " --diarization BACKEND pyannote | modal (default: pyannote for --cpu, modal for --gpu/--hosted)"
err " --translation BACKEND marian | modal | passthrough (default: marian for --cpu, modal for --gpu/--hosted)"
err " --padding BACKEND pyav | modal (default: pyav for --cpu, modal for --gpu/--hosted)"
err " --mixdown BACKEND pyav | modal (default: pyav for --cpu, modal for --gpu/--hosted)"
err ""
err "Local LLM (optional):"
err " --ollama-gpu Local Ollama with GPU (for summarization/topics)"
err " --ollama-cpu Local Ollama on CPU (for summarization/topics)"
@@ -255,15 +443,21 @@ if [[ -z "$MODEL_MODE" ]]; then
err " --garage Local S3-compatible storage (Garage)"
err " --caddy Caddy reverse proxy with self-signed cert"
err " --domain DOMAIN Use a real domain with Let's Encrypt HTTPS (implies --caddy)"
err " --custom-ca PATH Custom CA cert (dir with ca.crt[+server.pem+server-key.pem] or single PEM file)"
err " --extra-ca FILE Additional CA cert to trust (repeatable for multiple CAs)"
err " --password PASS Enable password auth (admin@localhost) instead of public mode"
err " --build Build backend/frontend images from source instead of pulling"
err ""
err "Tip: After your first run, re-run with no flags to reuse the same configuration."
exit 1
fi
# Build profiles list — one profile per feature
# Only --gpu needs a compose profile; --cpu and --hosted use in-process/remote backends
# Hatchet + hatchet-worker-llm are always-on (no profile needed).
# gpu/cpu profiles only control the ML container (transcription service).
COMPOSE_PROFILES=()
[[ "$MODEL_MODE" == "gpu" ]] && COMPOSE_PROFILES+=("gpu")
[[ "$MODEL_MODE" == "cpu" ]] && COMPOSE_PROFILES+=("cpu")
[[ -n "$OLLAMA_MODE" ]] && COMPOSE_PROFILES+=("$OLLAMA_MODE")
[[ "$USE_GARAGE" == "true" ]] && COMPOSE_PROFILES+=("garage")
[[ "$USE_CADDY" == "true" ]] && COMPOSE_PROFILES+=("caddy")
@@ -278,9 +472,38 @@ OLLAMA_SVC=""
[[ "$OLLAMA_MODE" == "ollama-gpu" ]] && USES_OLLAMA=true && OLLAMA_SVC="ollama"
[[ "$OLLAMA_MODE" == "ollama-cpu" ]] && USES_OLLAMA=true && OLLAMA_SVC="ollama-cpu"
# Resolve effective backend per service (override wins over base mode default)
case "$MODEL_MODE" in
gpu|hosted)
EFF_TRANSCRIPT="${OVERRIDE_TRANSCRIPT:-modal}"
EFF_DIARIZATION="${OVERRIDE_DIARIZATION:-modal}"
EFF_TRANSLATION="${OVERRIDE_TRANSLATION:-modal}"
EFF_PADDING="${OVERRIDE_PADDING:-modal}"
EFF_MIXDOWN="${OVERRIDE_MIXDOWN:-modal}"
;;
cpu)
EFF_TRANSCRIPT="${OVERRIDE_TRANSCRIPT:-whisper}"
EFF_DIARIZATION="${OVERRIDE_DIARIZATION:-pyannote}"
EFF_TRANSLATION="${OVERRIDE_TRANSLATION:-marian}"
EFF_PADDING="${OVERRIDE_PADDING:-pyav}"
EFF_MIXDOWN="${OVERRIDE_MIXDOWN:-pyav}"
;;
esac
# Check if any per-service overrides were provided
HAS_OVERRIDES=false
[[ -n "$OVERRIDE_TRANSCRIPT" ]] && HAS_OVERRIDES=true
[[ -n "$OVERRIDE_DIARIZATION" ]] && HAS_OVERRIDES=true
[[ -n "$OVERRIDE_TRANSLATION" ]] && HAS_OVERRIDES=true
[[ -n "$OVERRIDE_PADDING" ]] && HAS_OVERRIDES=true
[[ -n "$OVERRIDE_MIXDOWN" ]] && HAS_OVERRIDES=true
# Human-readable mode string for display
MODE_DISPLAY="$MODEL_MODE"
[[ -n "$OLLAMA_MODE" ]] && MODE_DISPLAY="$MODEL_MODE + $OLLAMA_MODE"
if [[ "$HAS_OVERRIDES" == "true" ]]; then
MODE_DISPLAY="$MODE_DISPLAY (overrides: transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION, translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN)"
fi
# =========================================================
# Step 0: Prerequisites
@@ -364,6 +587,103 @@ print(f'pbkdf2:sha256:100000\$\$' + salt + '\$\$' + dk.hex())
ok "Secrets ready"
}
# =========================================================
# Step 1b: Custom CA certificate setup
# =========================================================
step_custom_ca() {
if [[ "$USE_CUSTOM_CA" != "true" ]]; then
# Clean up stale override from previous runs
rm -f "$ROOT_DIR/docker-compose.ca.yml"
return
fi
info "Configuring custom CA certificate"
local certs_dir="$ROOT_DIR/certs"
mkdir -p "$certs_dir"
# Stage CA certificate (skip copy if source and dest are the same file)
local ca_dest="$certs_dir/ca.crt"
local src_id dst_id
src_id=$(ls -i "$CA_CERT_PATH" 2>/dev/null | awk '{print $1}')
dst_id=$(ls -i "$ca_dest" 2>/dev/null | awk '{print $1}')
if [[ "$src_id" != "$dst_id" ]] || [[ -z "$dst_id" ]]; then
cp "$CA_CERT_PATH" "$ca_dest"
fi
chmod 644 "$ca_dest"
ok "CA certificate staged at certs/ca.crt"
# Append extra CA certs (--extra-ca flags)
for extra_ca in "${EXTRA_CA_FILES[@]+"${EXTRA_CA_FILES[@]}"}"; do
if ! head -1 "$extra_ca" | grep -q "BEGIN"; then
warn "Skipping $extra_ca — does not appear to be PEM format"
continue
fi
echo "" >> "$ca_dest"
cat "$extra_ca" >> "$ca_dest"
ok "Appended extra CA: $extra_ca"
done
# Stage TLS cert/key if present (for Caddy)
if [[ -n "$TLS_CERT_PATH" ]]; then
local cert_dest="$certs_dir/server.pem"
local key_dest="$certs_dir/server-key.pem"
src_id=$(ls -i "$TLS_CERT_PATH" 2>/dev/null | awk '{print $1}')
dst_id=$(ls -i "$cert_dest" 2>/dev/null | awk '{print $1}')
if [[ "$src_id" != "$dst_id" ]] || [[ -z "$dst_id" ]]; then
cp "$TLS_CERT_PATH" "$cert_dest"
cp "$TLS_KEY_PATH" "$key_dest"
fi
chmod 644 "$cert_dest"
chmod 600 "$key_dest"
ok "TLS cert/key staged at certs/server.pem, certs/server-key.pem"
fi
# Generate docker-compose.ca.yml override
local ca_override="$ROOT_DIR/docker-compose.ca.yml"
cat > "$ca_override" << 'CAEOF'
# Generated by setup-selfhosted.sh — custom CA trust for backend services.
# Do not edit manually; re-run setup-selfhosted.sh with --custom-ca to regenerate.
services:
server:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
worker:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
beat:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
hatchet-worker-llm:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
hatchet-worker-cpu:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
gpu:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
cpu:
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
web:
environment:
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/custom-ca.crt
volumes:
- ./certs/ca.crt:/usr/local/share/ca-certificates/custom-ca.crt:ro
CAEOF
# If TLS cert/key present, also mount certs dir into Caddy
if [[ -n "$TLS_CERT_PATH" ]]; then
cat >> "$ca_override" << 'CADDYCAEOF'
caddy:
volumes:
- ./certs:/etc/caddy/certs:ro
CADDYCAEOF
fi
ok "Generated docker-compose.ca.yml override"
}
# =========================================================
# Step 2: Generate server/.env
# =========================================================
@@ -430,54 +750,30 @@ step_server_env() {
env_set "$SERVER_ENV" "WEBRTC_HOST" "$PRIMARY_IP"
fi
# Specialized models — backend configuration per mode
# Specialized models — backend configuration per service
env_set "$SERVER_ENV" "DIARIZATION_ENABLED" "true"
# Resolve the URL for modal backends
local modal_url=""
case "$MODEL_MODE" in
gpu)
# GPU container aliased as "transcription" on docker network
env_set "$SERVER_ENV" "TRANSCRIPT_BACKEND" "modal"
env_set "$SERVER_ENV" "TRANSCRIPT_URL" "http://transcription:8000"
env_set "$SERVER_ENV" "TRANSCRIPT_MODAL_API_KEY" "selfhosted"
env_set "$SERVER_ENV" "DIARIZATION_BACKEND" "modal"
env_set "$SERVER_ENV" "DIARIZATION_URL" "http://transcription:8000"
env_set "$SERVER_ENV" "TRANSLATION_BACKEND" "modal"
env_set "$SERVER_ENV" "TRANSLATE_URL" "http://transcription:8000"
env_set "$SERVER_ENV" "PADDING_BACKEND" "modal"
env_set "$SERVER_ENV" "PADDING_URL" "http://transcription:8000"
ok "ML backends: GPU container (modal)"
;;
cpu)
# In-process backends — no ML service container needed
env_set "$SERVER_ENV" "TRANSCRIPT_BACKEND" "whisper"
env_set "$SERVER_ENV" "DIARIZATION_BACKEND" "pyannote"
env_set "$SERVER_ENV" "TRANSLATION_BACKEND" "marian"
env_set "$SERVER_ENV" "PADDING_BACKEND" "pyav"
ok "ML backends: in-process CPU (whisper/pyannote/marian/pyav)"
modal_url="http://transcription:8000"
;;
hosted)
# Remote GPU service — user provides URL
local gpu_url=""
if env_has_key "$SERVER_ENV" "TRANSCRIPT_URL"; then
gpu_url=$(env_get "$SERVER_ENV" "TRANSCRIPT_URL")
modal_url=$(env_get "$SERVER_ENV" "TRANSCRIPT_URL")
fi
if [[ -z "$gpu_url" ]] && [[ -t 0 ]]; then
if [[ -z "$modal_url" ]] && [[ -t 0 ]]; then
echo ""
info "Enter the URL of your remote GPU service (e.g. https://gpu.example.com)"
read -rp " GPU service URL: " gpu_url
read -rp " GPU service URL: " modal_url
fi
if [[ -z "$gpu_url" ]]; then
if [[ -z "$modal_url" ]]; then
err "GPU service URL required for --hosted mode."
err "Set TRANSCRIPT_URL in server/.env or provide it interactively."
exit 1
fi
env_set "$SERVER_ENV" "TRANSCRIPT_BACKEND" "modal"
env_set "$SERVER_ENV" "TRANSCRIPT_URL" "$gpu_url"
env_set "$SERVER_ENV" "DIARIZATION_BACKEND" "modal"
env_set "$SERVER_ENV" "DIARIZATION_URL" "$gpu_url"
env_set "$SERVER_ENV" "TRANSLATION_BACKEND" "modal"
env_set "$SERVER_ENV" "TRANSLATE_URL" "$gpu_url"
env_set "$SERVER_ENV" "PADDING_BACKEND" "modal"
env_set "$SERVER_ENV" "PADDING_URL" "$gpu_url"
# API key for remote service
local gpu_api_key=""
if env_has_key "$SERVER_ENV" "TRANSCRIPT_MODAL_API_KEY"; then
@@ -489,15 +785,106 @@ step_server_env() {
if [[ -n "$gpu_api_key" ]]; then
env_set "$SERVER_ENV" "TRANSCRIPT_MODAL_API_KEY" "$gpu_api_key"
fi
ok "ML backends: remote hosted ($gpu_url)"
;;
cpu)
# CPU mode: modal_url stays empty. If services are overridden to modal,
# the user must configure the URL (TRANSCRIPT_URL etc.) in server/.env manually.
# We intentionally do NOT read from existing env here to avoid overwriting
# per-service URLs with a stale TRANSCRIPT_URL from a previous --gpu run.
;;
esac
# Set each service backend independently using effective backends
# Transcript
case "$EFF_TRANSCRIPT" in
modal)
env_set "$SERVER_ENV" "TRANSCRIPT_BACKEND" "modal"
if [[ -n "$modal_url" ]]; then
env_set "$SERVER_ENV" "TRANSCRIPT_URL" "$modal_url"
fi
[[ "$MODEL_MODE" == "gpu" ]] && env_set "$SERVER_ENV" "TRANSCRIPT_MODAL_API_KEY" "selfhosted"
;;
whisper)
env_set "$SERVER_ENV" "TRANSCRIPT_BACKEND" "whisper"
;;
esac
# Diarization
case "$EFF_DIARIZATION" in
modal)
env_set "$SERVER_ENV" "DIARIZATION_BACKEND" "modal"
if [[ -n "$modal_url" ]]; then
env_set "$SERVER_ENV" "DIARIZATION_URL" "$modal_url"
fi
;;
pyannote)
env_set "$SERVER_ENV" "DIARIZATION_BACKEND" "pyannote"
;;
esac
# Translation
case "$EFF_TRANSLATION" in
modal)
env_set "$SERVER_ENV" "TRANSLATION_BACKEND" "modal"
if [[ -n "$modal_url" ]]; then
env_set "$SERVER_ENV" "TRANSLATE_URL" "$modal_url"
fi
;;
marian)
env_set "$SERVER_ENV" "TRANSLATION_BACKEND" "marian"
;;
passthrough)
env_set "$SERVER_ENV" "TRANSLATION_BACKEND" "passthrough"
;;
esac
# Padding
case "$EFF_PADDING" in
modal)
env_set "$SERVER_ENV" "PADDING_BACKEND" "modal"
if [[ -n "$modal_url" ]]; then
env_set "$SERVER_ENV" "PADDING_URL" "$modal_url"
fi
;;
pyav)
env_set "$SERVER_ENV" "PADDING_BACKEND" "pyav"
;;
esac
# Mixdown
case "$EFF_MIXDOWN" in
modal)
env_set "$SERVER_ENV" "MIXDOWN_BACKEND" "modal"
if [[ -n "$modal_url" ]]; then
env_set "$SERVER_ENV" "MIXDOWN_URL" "$modal_url"
fi
;;
pyav)
env_set "$SERVER_ENV" "MIXDOWN_BACKEND" "pyav"
;;
esac
# Warn about modal overrides in CPU mode that need URL configuration
if [[ "$MODEL_MODE" == "cpu" ]] && [[ -z "$modal_url" ]]; then
local needs_url=false
[[ "$EFF_TRANSCRIPT" == "modal" ]] && needs_url=true
[[ "$EFF_DIARIZATION" == "modal" ]] && needs_url=true
[[ "$EFF_TRANSLATION" == "modal" ]] && needs_url=true
[[ "$EFF_PADDING" == "modal" ]] && needs_url=true
[[ "$EFF_MIXDOWN" == "modal" ]] && needs_url=true
if [[ "$needs_url" == "true" ]]; then
warn "One or more services are set to 'modal' but no service URL is configured."
warn "Set TRANSCRIPT_URL (and optionally TRANSCRIPT_MODAL_API_KEY) in server/.env"
warn "to point to your GPU service, then re-run this script."
fi
fi
ok "ML backends: transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION, translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN"
# HuggingFace token for gated models (pyannote diarization)
# --gpu: written to root .env (docker compose passes to GPU container)
# --cpu: written to both root .env and server/.env (in-process pyannote needs it)
# --hosted: not needed (remote service handles its own auth)
if [[ "$MODEL_MODE" != "hosted" ]]; then
# Needed when: GPU container is running (MODEL_MODE=gpu), or diarization uses pyannote in-process
# Not needed when: all modal services point to a remote hosted URL with its own auth
if [[ "$MODEL_MODE" == "gpu" ]] || [[ "$EFF_DIARIZATION" == "pyannote" ]]; then
local root_env="$ROOT_DIR/.env"
local current_hf_token="${HF_TOKEN:-}"
if [[ -f "$root_env" ]] && env_has_key "$root_env" "HF_TOKEN"; then
@@ -516,8 +903,8 @@ step_server_env() {
touch "$root_env"
env_set "$root_env" "HF_TOKEN" "$current_hf_token"
export HF_TOKEN="$current_hf_token"
# In CPU mode, server process needs HF_TOKEN directly
if [[ "$MODEL_MODE" == "cpu" ]]; then
# When diarization runs in-process (pyannote), server process needs HF_TOKEN directly
if [[ "$EFF_DIARIZATION" == "pyannote" ]]; then
env_set "$SERVER_ENV" "HF_TOKEN" "$current_hf_token"
fi
ok "HF_TOKEN configured"
@@ -550,19 +937,21 @@ step_server_env() {
fi
fi
# CPU mode: increase file processing timeouts (default 600s is too short for long audio on CPU)
if [[ "$MODEL_MODE" == "cpu" ]]; then
# Increase file processing timeouts for CPU backends (default 600s is too short for long audio on CPU)
if [[ "$EFF_TRANSCRIPT" == "whisper" ]]; then
env_set "$SERVER_ENV" "TRANSCRIPT_FILE_TIMEOUT" "3600"
fi
if [[ "$EFF_DIARIZATION" == "pyannote" ]]; then
env_set "$SERVER_ENV" "DIARIZATION_FILE_TIMEOUT" "3600"
ok "CPU mode — file processing timeouts set to 3600s (1 hour)"
fi
if [[ "$EFF_TRANSCRIPT" == "whisper" ]] || [[ "$EFF_DIARIZATION" == "pyannote" ]]; then
ok "CPU backend(s) detected — file processing timeouts set to 3600s (1 hour)"
fi
# If Daily.co is manually configured, ensure Hatchet connectivity vars are set
if env_has_key "$SERVER_ENV" "DAILY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "DAILY_API_KEY")" ]]; then
env_set "$SERVER_ENV" "HATCHET_CLIENT_SERVER_URL" "http://hatchet:8888"
env_set "$SERVER_ENV" "HATCHET_CLIENT_HOST_PORT" "hatchet:7077"
ok "Daily.co detected — Hatchet connectivity configured"
fi
# Hatchet is always required (file, live, and multitrack pipelines all use it)
env_set "$SERVER_ENV" "HATCHET_CLIENT_SERVER_URL" "http://hatchet:8888"
env_set "$SERVER_ENV" "HATCHET_CLIENT_HOST_PORT" "hatchet:7077"
ok "Hatchet connectivity configured (workflow engine for processing pipelines)"
ok "server/.env ready"
}
@@ -799,7 +1188,25 @@ step_caddyfile() {
rm -rf "$caddyfile"
fi
if [[ -n "$CUSTOM_DOMAIN" ]]; then
if [[ -n "$TLS_CERT_PATH" ]] && [[ -n "$CUSTOM_DOMAIN" ]]; then
# Custom domain with user-provided TLS certificate (from --custom-ca directory)
cat > "$caddyfile" << CADDYEOF
# Generated by setup-selfhosted.sh — Custom TLS cert for $CUSTOM_DOMAIN
$CUSTOM_DOMAIN {
tls /etc/caddy/certs/server.pem /etc/caddy/certs/server-key.pem
handle /v1/* {
reverse_proxy server:1250
}
handle /health {
reverse_proxy server:1250
}
handle {
reverse_proxy web:3000
}
}
CADDYEOF
ok "Created Caddyfile for $CUSTOM_DOMAIN (custom TLS certificate)"
elif [[ -n "$CUSTOM_DOMAIN" ]]; then
# Real domain: Caddy auto-provisions Let's Encrypt certificate
cat > "$caddyfile" << CADDYEOF
# Generated by setup-selfhosted.sh — Let's Encrypt HTTPS for $CUSTOM_DOMAIN
@@ -886,15 +1293,22 @@ step_services() {
compose_cmd pull server web || warn "Pull failed — using cached images"
fi
# Build hatchet workers if Daily.co is configured (same backend image)
if [[ "$DAILY_DETECTED" == "true" ]] && [[ "$BUILD_IMAGES" == "true" ]]; then
# Hatchet is always needed (all processing pipelines use it)
local NEEDS_HATCHET=true
# Build hatchet workers if Hatchet is needed (same backend image)
if [[ "$NEEDS_HATCHET" == "true" ]] && [[ "$BUILD_IMAGES" == "true" ]]; then
info "Building Hatchet worker images..."
compose_cmd build hatchet-worker-cpu hatchet-worker-llm
if [[ "$DAILY_DETECTED" == "true" ]]; then
compose_cmd build hatchet-worker-cpu hatchet-worker-llm
else
compose_cmd build hatchet-worker-llm
fi
ok "Hatchet worker images built"
fi
# Ensure hatchet database exists before starting hatchet (init-hatchet-db.sql only runs on fresh postgres volumes)
if [[ "$DAILY_DETECTED" == "true" ]]; then
if [[ "$NEEDS_HATCHET" == "true" ]]; then
info "Ensuring postgres is running for Hatchet database setup..."
compose_cmd up -d postgres
local pg_ready=false
@@ -959,9 +1373,9 @@ step_health() {
warn "Check with: docker compose -f docker-compose.selfhosted.yml logs gpu"
fi
elif [[ "$MODEL_MODE" == "cpu" ]]; then
ok "CPU mode — ML processing runs in-process on server/worker (no separate service)"
ok "CPU mode — in-process backends run on server/worker (transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION, translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN)"
elif [[ "$MODEL_MODE" == "hosted" ]]; then
ok "Hosted mode — ML processing via remote GPU service (no local health check)"
ok "Hosted mode — ML processing via remote GPU service (transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION, translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN)"
fi
# Ollama (if applicable)
@@ -1049,24 +1463,22 @@ step_health() {
fi
fi
# Hatchet (if Daily.co detected)
if [[ "$DAILY_DETECTED" == "true" ]]; then
info "Waiting for Hatchet workflow engine..."
local hatchet_ok=false
for i in $(seq 1 60); do
if curl -sf http://localhost:8888/api/live > /dev/null 2>&1; then
hatchet_ok=true
break
fi
echo -ne "\r Waiting for Hatchet... ($i/60)"
sleep 3
done
echo ""
if [[ "$hatchet_ok" == "true" ]]; then
ok "Hatchet workflow engine healthy"
else
warn "Hatchet not ready yet. Check: docker compose logs hatchet"
# Hatchet (always-on)
info "Waiting for Hatchet workflow engine..."
local hatchet_ok=false
for i in $(seq 1 60); do
if curl -sf http://localhost:8888/api/live > /dev/null 2>&1; then
hatchet_ok=true
break
fi
echo -ne "\r Waiting for Hatchet... ($i/60)"
sleep 3
done
echo ""
if [[ "$hatchet_ok" == "true" ]]; then
ok "Hatchet workflow engine healthy"
else
warn "Hatchet not ready yet. Check: docker compose logs hatchet"
fi
# LLM warning for non-Ollama modes
@@ -1087,12 +1499,10 @@ step_health() {
}
# =========================================================
# Step 8: Hatchet token generation (Daily.co only)
# Step 8: Hatchet token generation (gpu/cpu/Daily.co)
# =========================================================
step_hatchet_token() {
if [[ "$DAILY_DETECTED" != "true" ]]; then
return
fi
# Hatchet is always required — no gating needed
# Skip if token already set
if env_has_key "$SERVER_ENV" "HATCHET_CLIENT_TOKEN" && [[ -n "$(env_get "$SERVER_ENV" "HATCHET_CLIENT_TOKEN")" ]]; then
@@ -1147,7 +1557,9 @@ step_hatchet_token() {
# Restart services that need the token
info "Restarting services with new Hatchet token..."
compose_cmd restart server worker hatchet-worker-cpu hatchet-worker-llm
local restart_services="server worker hatchet-worker-llm"
[[ "$DAILY_DETECTED" == "true" ]] && restart_services="$restart_services hatchet-worker-cpu"
compose_cmd restart $restart_services
ok "Services restarted with Hatchet token"
}
@@ -1161,10 +1573,16 @@ main() {
echo "=========================================="
echo ""
echo " Models: $MODEL_MODE"
if [[ "$HAS_OVERRIDES" == "true" ]]; then
echo " transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION"
echo " translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN"
fi
echo " LLM: ${OLLAMA_MODE:-external}"
echo " Garage: $USE_GARAGE"
echo " Caddy: $USE_CADDY"
[[ -n "$CUSTOM_DOMAIN" ]] && echo " Domain: $CUSTOM_DOMAIN"
[[ "$USE_CUSTOM_CA" == "true" ]] && echo " CA: Custom ($CUSTOM_CA)"
[[ -n "$TLS_CERT_PATH" ]] && echo " TLS: Custom cert (from $CUSTOM_CA)"
[[ "$BUILD_IMAGES" == "true" ]] && echo " Build: from source"
echo ""
@@ -1195,6 +1613,8 @@ main() {
echo ""
step_secrets
echo ""
step_custom_ca
echo ""
step_server_env
echo ""
@@ -1216,28 +1636,23 @@ main() {
ok "Daily.co detected — enabling Hatchet workflow services"
fi
# Generate .env.hatchet for hatchet dashboard config
if [[ "$DAILY_DETECTED" == "true" ]]; then
local hatchet_server_url hatchet_cookie_domain
if [[ -n "$CUSTOM_DOMAIN" ]]; then
hatchet_server_url="https://${CUSTOM_DOMAIN}:8888"
hatchet_cookie_domain="$CUSTOM_DOMAIN"
elif [[ -n "$PRIMARY_IP" ]]; then
hatchet_server_url="http://${PRIMARY_IP}:8888"
hatchet_cookie_domain="$PRIMARY_IP"
else
hatchet_server_url="http://localhost:8888"
hatchet_cookie_domain="localhost"
fi
cat > "$ROOT_DIR/.env.hatchet" << EOF
# Generate .env.hatchet for hatchet dashboard config (always needed)
local hatchet_server_url hatchet_cookie_domain
if [[ -n "$CUSTOM_DOMAIN" ]]; then
hatchet_server_url="https://${CUSTOM_DOMAIN}:8888"
hatchet_cookie_domain="$CUSTOM_DOMAIN"
elif [[ -n "$PRIMARY_IP" ]]; then
hatchet_server_url="http://${PRIMARY_IP}:8888"
hatchet_cookie_domain="$PRIMARY_IP"
else
hatchet_server_url="http://localhost:8888"
hatchet_cookie_domain="localhost"
fi
cat > "$ROOT_DIR/.env.hatchet" << EOF
SERVER_URL=$hatchet_server_url
SERVER_AUTH_COOKIE_DOMAIN=$hatchet_cookie_domain
EOF
ok "Generated .env.hatchet (dashboard URL=$hatchet_server_url)"
else
# Create empty .env.hatchet so compose doesn't fail if dailyco profile is ever activated manually
touch "$ROOT_DIR/.env.hatchet"
fi
ok "Generated .env.hatchet (dashboard URL=$hatchet_server_url)"
step_www_env
echo ""
@@ -1274,7 +1689,13 @@ EOF
echo " API: server:1250 (or localhost:1250 from host)"
fi
echo ""
echo " Models: $MODEL_MODE (transcription/diarization/translation)"
if [[ "$HAS_OVERRIDES" == "true" ]]; then
echo " Models: $MODEL_MODE base + overrides"
echo " transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION"
echo " translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN"
else
echo " Models: $MODEL_MODE (transcription/diarization/translation/padding)"
fi
[[ "$USE_GARAGE" == "true" ]] && echo " Storage: Garage (local S3)"
[[ "$USE_GARAGE" != "true" ]] && echo " Storage: External S3"
[[ "$USES_OLLAMA" == "true" ]] && echo " LLM: Ollama ($OLLAMA_MODEL) for summarization/topics"
@@ -1282,9 +1703,20 @@ EOF
[[ "$DAILY_DETECTED" == "true" ]] && echo " Video: Daily.co (live rooms + multitrack processing via Hatchet)"
[[ "$WHEREBY_DETECTED" == "true" ]] && echo " Video: Whereby (live rooms)"
[[ "$ANY_PLATFORM_DETECTED" != "true" ]] && echo " Video: None (rooms disabled)"
if [[ "$USE_CUSTOM_CA" == "true" ]]; then
echo " CA: Custom (certs/ca.crt)"
[[ -n "$TLS_CERT_PATH" ]] && echo " TLS: Custom cert (certs/server.pem)"
fi
echo ""
if [[ "$USE_CUSTOM_CA" == "true" ]]; then
echo " NOTE: Clients must trust the CA certificate to avoid browser warnings."
echo " CA cert location: certs/ca.crt"
echo " See docsv2/custom-ca-setup.md for instructions."
echo ""
fi
echo " To stop: docker compose -f docker-compose.selfhosted.yml down"
echo " To re-run: ./scripts/setup-selfhosted.sh $*"
echo " To re-run: ./scripts/setup-selfhosted.sh (replays saved config)"
echo " Last args: $*"
echo ""
}

View File

@@ -6,7 +6,7 @@ ENV PYTHONUNBUFFERED=1 \
# builder install base dependencies
WORKDIR /tmp
RUN apt-get update && apt-get install -y curl ffmpeg && apt-get clean
RUN apt-get update && apt-get install -y curl ffmpeg ca-certificates && apt-get clean
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"
@@ -18,7 +18,7 @@ COPY pyproject.toml uv.lock README.md /app/
RUN uv sync --compile-bytecode --locked
# bootstrap
COPY alembic.ini runserver.sh /app/
COPY alembic.ini docker-entrypoint.sh runserver.sh /app/
COPY images /app/images
COPY migrations /app/migrations
COPY reflector /app/reflector
@@ -35,4 +35,6 @@ RUN if [ "$(uname -m)" = "aarch64" ] && [ ! -f /usr/lib/libgomp.so.1 ]; then \
# Pre-check just to make sure the image will not fail
RUN uv run python -c "import silero_vad.model"
CMD ["./runserver.sh"]
RUN chmod +x /app/docker-entrypoint.sh
CMD ["./docker-entrypoint.sh"]

View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -e
# Custom CA certificate injection
# If a CA cert is mounted at this path (via docker-compose.ca.yml),
# add it to the system trust store and configure all Python SSL libraries.
CUSTOM_CA_PATH="/usr/local/share/ca-certificates/custom-ca.crt"
if [ -s "$CUSTOM_CA_PATH" ]; then
echo "[entrypoint] Custom CA certificate detected, updating trust store..."
update-ca-certificates 2>/dev/null
# update-ca-certificates creates a combined bundle (system + custom CAs)
COMBINED_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
export SSL_CERT_FILE="$COMBINED_BUNDLE"
export REQUESTS_CA_BUNDLE="$COMBINED_BUNDLE"
export CURL_CA_BUNDLE="$COMBINED_BUNDLE"
# Note: GRPC_DEFAULT_SSL_ROOTS_FILE_PATH is intentionally NOT set here.
# Setting it causes grpcio to attempt TLS on internal Hatchet connections
# that run without TLS (SERVER_GRPC_INSECURE=t), resulting in handshake failures.
# If you need gRPC with custom CA, set GRPC_DEFAULT_SSL_ROOTS_FILE_PATH explicitly.
echo "[entrypoint] CA trust store updated (SSL_CERT_FILE=$COMBINED_BUNDLE)"
fi
exec ./runserver.sh

View File

@@ -419,3 +419,18 @@ User-room broadcasts to `user:{user_id}`:
- `TRANSCRIPT_STATUS`
- `TRANSCRIPT_FINAL_TITLE`
- `TRANSCRIPT_DURATION`
## Failed Runs Monitor (Hatchet Cron)
A `FailedRunsMonitor` Hatchet cron workflow runs hourly (`0 * * * *`) and checks for failed pipeline runs
(DiarizationPipeline, FilePipeline, LivePostProcessingPipeline) in the last hour. For each failed run,
it renders a DAG status overview and posts it to Zulip.
**Required env vars** (all must be set to enable):
- `ZULIP_REALM` — Zulip server hostname
- `ZULIP_API_KEY` — Zulip bot API key
- `ZULIP_BOT_EMAIL` — Zulip bot email
- `ZULIP_DAG_STREAM` — Zulip stream for alerts
- `ZULIP_DAG_TOPIC` — Zulip topic for alerts
If any of these are unset, the monitor workflow is not registered with the Hatchet worker.

View File

@@ -0,0 +1,47 @@
"""add soft delete fields to transcript and recording
Revision ID: 501c73a6b0d5
Revises: e1f093f7f124
Create Date: 2026-03-19 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "501c73a6b0d5"
down_revision: Union[str, None] = "e1f093f7f124"
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("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"recording",
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"idx_transcript_not_deleted",
"transcript",
["id"],
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.create_index(
"idx_recording_not_deleted",
"recording",
["id"],
postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("idx_recording_not_deleted", table_name="recording")
op.drop_index("idx_transcript_not_deleted", table_name="transcript")
op.drop_column("recording", "deleted_at")
op.drop_column("transcript", "deleted_at")

View File

@@ -0,0 +1,29 @@
"""add email_recipients to meeting
Revision ID: a2b3c4d5e6f7
Revises: 501c73a6b0d5
Create Date: 2026-03-20 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "a2b3c4d5e6f7"
down_revision: Union[str, None] = "501c73a6b0d5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"meeting",
sa.Column("email_recipients", JSONB, nullable=True),
)
def downgrade() -> None:
op.drop_column("meeting", "email_recipients")

View File

@@ -0,0 +1,28 @@
"""add email_transcript_to to room
Revision ID: b4c7e8f9a012
Revises: a2b3c4d5e6f7
Create Date: 2026-03-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b4c7e8f9a012"
down_revision: Union[str, None] = "a2b3c4d5e6f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"room",
sa.Column("email_transcript_to", sa.String(), nullable=True),
)
def downgrade() -> None:
op.drop_column("room", "email_transcript_to")

View File

@@ -40,6 +40,8 @@ dependencies = [
"icalendar>=6.0.0",
"hatchet-sdk==1.22.16",
"pydantic>=2.12.5",
"aiosmtplib>=3.0.0",
"email-validator>=2.0.0",
]
[dependency-groups]
@@ -116,9 +118,10 @@ source = ["reflector"]
ENVIRONMENT = "pytest"
DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test"
AUTH_BACKEND = "jwt"
HATCHET_CLIENT_TOKEN = "test-dummy-token"
[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 --ignore=tests/integration"
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [

View File

@@ -13,18 +13,21 @@ from reflector.events import subscribers_shutdown, subscribers_startup
from reflector.logger import logger
from reflector.metrics import metrics_init
from reflector.settings import settings
from reflector.views.config import router as config_router
from reflector.views.daily import router as daily_router
from reflector.views.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router
from reflector.views.rtc_offer import router as rtc_offer_router
from reflector.views.transcripts import router as transcripts_router
from reflector.views.transcripts_audio import router as transcripts_audio_router
from reflector.views.transcripts_download import router as transcripts_download_router
from reflector.views.transcripts_participants import (
router as transcripts_participants_router,
)
from reflector.views.transcripts_process import router as transcripts_process_router
from reflector.views.transcripts_speaker import router as transcripts_speaker_router
from reflector.views.transcripts_upload import router as transcripts_upload_router
from reflector.views.transcripts_video import router as transcripts_video_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.user import router as user_router
@@ -97,12 +100,15 @@ app.include_router(transcripts_audio_router, prefix="/v1")
app.include_router(transcripts_participants_router, prefix="/v1")
app.include_router(transcripts_speaker_router, prefix="/v1")
app.include_router(transcripts_upload_router, prefix="/v1")
app.include_router(transcripts_download_router, prefix="/v1")
app.include_router(transcripts_video_router, prefix="/v1")
app.include_router(transcripts_websocket_router, prefix="/v1")
app.include_router(transcripts_webrtc_router, prefix="/v1")
app.include_router(transcripts_process_router, prefix="/v1")
app.include_router(user_router, prefix="/v1")
app.include_router(user_api_keys_router, prefix="/v1")
app.include_router(user_ws_router, prefix="/v1")
app.include_router(config_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1")
app.include_router(daily_router, prefix="/v1/daily")

View File

@@ -12,6 +12,7 @@ AccessTokenInfo = auth_module.AccessTokenInfo
authenticated = auth_module.authenticated
current_user = auth_module.current_user
current_user_optional = auth_module.current_user_optional
current_user_optional_if_public_mode = auth_module.current_user_optional_if_public_mode
parse_ws_bearer_token = auth_module.parse_ws_bearer_token
current_user_ws_optional = auth_module.current_user_ws_optional
verify_raw_token = auth_module.verify_raw_token

View File

@@ -129,6 +129,17 @@ async def current_user_optional(
return await _authenticate_user(jwt_token, api_key, jwtauth)
async def current_user_optional_if_public_mode(
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
api_key: Annotated[Optional[str], Depends(api_key_header)],
jwtauth: JWTAuth = Depends(),
) -> Optional[UserInfo]:
user = await _authenticate_user(jwt_token, api_key, jwtauth)
if user is None and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def parse_ws_bearer_token(
websocket: "WebSocket",
) -> tuple[Optional[str], Optional[str]]:

View File

@@ -21,6 +21,11 @@ def current_user_optional():
return None
def current_user_optional_if_public_mode():
# auth_none means no authentication at all — always public
return None
def parse_ws_bearer_token(websocket):
return None, None

View File

@@ -150,6 +150,16 @@ async def current_user_optional(
return await _authenticate_user(jwt_token, api_key)
async def current_user_optional_if_public_mode(
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
api_key: Annotated[Optional[str], Depends(api_key_header)],
) -> Optional[UserInfo]:
user = await _authenticate_user(jwt_token, api_key)
if user is None and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
# --- WebSocket auth (same pattern as auth_jwt.py) ---
def parse_ws_bearer_token(
websocket: "WebSocket",

View File

@@ -1,3 +1,4 @@
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Any, Literal
@@ -66,6 +67,8 @@ meetings = sa.Table(
# Daily.co composed video (Brady Bunch grid layout) - Daily.co only, not Whereby
sa.Column("daily_composed_video_s3_key", sa.String, nullable=True),
sa.Column("daily_composed_video_duration", sa.Integer, nullable=True),
# Email recipients for transcript notification
sa.Column("email_recipients", JSONB, nullable=True),
sa.Index("idx_meeting_room_id", "room_id"),
sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
)
@@ -116,6 +119,9 @@ class Meeting(BaseModel):
# Daily.co composed video (Brady Bunch grid) - Daily.co only
daily_composed_video_s3_key: str | None = None
daily_composed_video_duration: int | None = None
# Email recipients for transcript notification
# Each entry is {"email": str, "include_link": bool} or a legacy plain str
email_recipients: list[dict | str] | None = None
class MeetingController:
@@ -388,6 +394,36 @@ class MeetingController:
# If was_null=False, the WHERE clause prevented the update
return was_null
@asynccontextmanager
async def transaction(self):
"""A context manager for database transaction."""
async with get_database().transaction(isolation="serializable"):
yield
async def add_email_recipient(
self, meeting_id: str, email: str, *, include_link: bool = True
) -> list[dict]:
"""Add an email to the meeting's email_recipients list (no duplicates).
Each entry is stored as {"email": str, "include_link": bool}.
Legacy plain-string entries are normalised on read.
"""
async with self.transaction():
meeting = await self.get_by_id(meeting_id)
if not meeting:
raise ValueError(f"Meeting {meeting_id} not found")
# Normalise legacy string entries
current: list[dict] = [
entry
if isinstance(entry, dict)
else {"email": entry, "include_link": True}
for entry in (meeting.email_recipients or [])
]
if not any(r["email"] == email for r in current):
current.append({"email": email, "include_link": include_link})
await self.update_meeting(meeting_id, email_recipients=current)
return current
async def increment_num_clients(self, meeting_id: str) -> None:
"""Atomically increment participant count."""
query = (

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from typing import Literal
import sqlalchemy as sa
@@ -24,6 +24,7 @@ recordings = sa.Table(
),
sa.Column("meeting_id", sa.String),
sa.Column("track_keys", sa.JSON, nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Index("idx_recording_meeting_id", "meeting_id"),
)
@@ -40,6 +41,7 @@ class Recording(BaseModel):
# 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
deleted_at: datetime | None = None
@property
def is_multitrack(self) -> bool:
@@ -69,6 +71,18 @@ class RecordingController:
return Recording(**result) if result else None
async def remove_by_id(self, id: str) -> None:
query = (
recordings.update()
.where(recordings.c.id == id)
.values(deleted_at=datetime.now(timezone.utc))
)
await get_database().execute(query)
async def restore_by_id(self, id: str) -> None:
query = recordings.update().where(recordings.c.id == id).values(deleted_at=None)
await get_database().execute(query)
async def hard_delete_by_id(self, id: str) -> None:
query = recordings.delete().where(recordings.c.id == id)
await get_database().execute(query)
@@ -114,6 +128,7 @@ class RecordingController:
.where(
recordings.c.bucket_name == bucket_name,
recordings.c.track_keys.isnot(None),
recordings.c.deleted_at.is_(None),
or_(
transcripts.c.id.is_(None),
transcripts.c.status == "error",

View File

@@ -63,6 +63,7 @@ rooms = sqlalchemy.Table(
nullable=False,
server_default=sqlalchemy.sql.false(),
),
sqlalchemy.Column("email_transcript_to", sqlalchemy.String, nullable=True),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -92,6 +93,7 @@ class Room(BaseModel):
ics_last_etag: str | None = None
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
skip_consent: bool = False
email_transcript_to: str | None = None
class RoomController:
@@ -147,6 +149,7 @@ class RoomController:
ics_enabled: bool = False,
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
skip_consent: bool = False,
email_transcript_to: str | None = None,
):
"""
Add a new room
@@ -172,6 +175,7 @@ class RoomController:
"ics_enabled": ics_enabled,
"platform": platform,
"skip_consent": skip_consent,
"email_transcript_to": email_transcript_to,
}
room = Room(**room_data)

View File

@@ -138,6 +138,7 @@ class SearchParameters(BaseModel):
source_kind: SourceKind | None = None
from_datetime: datetime | None = None
to_datetime: datetime | None = None
include_deleted: bool = False
class SearchResultDB(BaseModel):
@@ -387,6 +388,11 @@ class SearchController:
transcripts.join(rooms, transcripts.c.room_id == rooms.c.id, isouter=True)
)
if params.include_deleted:
base_query = base_query.where(transcripts.c.deleted_at.isnot(None))
else:
base_query = base_query.where(transcripts.c.deleted_at.is_(None))
if params.query_text is not None:
# because already initialized based on params.query_text presence above
assert search_query is not None
@@ -394,7 +400,13 @@ class SearchController:
transcripts.c.search_vector_en.op("@@")(search_query)
)
if params.user_id:
if params.include_deleted:
# Trash view: only show user's own deleted transcripts.
# Defense-in-depth: require user_id to prevent leaking all users' trash.
if not params.user_id:
return [], 0
base_query = base_query.where(transcripts.c.user_id == params.user_id)
elif params.user_id:
base_query = base_query.where(
sqlalchemy.or_(
transcripts.c.user_id == params.user_id, rooms.c.is_shared
@@ -419,6 +431,8 @@ class SearchController:
if params.query_text is not None:
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
elif params.include_deleted:
order_by = sqlalchemy.desc(transcripts.c.deleted_at)
else:
order_by = sqlalchemy.desc(transcripts.c.created_at)

View File

@@ -24,7 +24,7 @@ from reflector.db.utils import is_postgresql
from reflector.logger import logger
from reflector.processors.types import Word as ProcessorWord
from reflector.settings import settings
from reflector.storage import get_transcripts_storage
from reflector.storage import get_source_storage, get_transcripts_storage
from reflector.utils import generate_uuid4
from reflector.utils.webvtt import topics_to_webvtt
@@ -91,6 +91,7 @@ transcripts = sqlalchemy.Table(
sqlalchemy.Column("webvtt", sqlalchemy.Text),
# Hatchet workflow run ID for resumption of failed workflows
sqlalchemy.Column("workflow_run_id", sqlalchemy.String),
sqlalchemy.Column("deleted_at", sqlalchemy.DateTime(timezone=True), nullable=True),
sqlalchemy.Column(
"change_seq",
sqlalchemy.BigInteger,
@@ -238,6 +239,7 @@ class Transcript(BaseModel):
webvtt: str | None = None
workflow_run_id: str | None = None # Hatchet workflow run ID for resumption
change_seq: int | None = None
deleted_at: datetime | None = None
@field_serializer("created_at", when_used="json")
def serialize_datetime(self, dt: datetime) -> str:
@@ -418,6 +420,8 @@ class TranscriptController:
rooms, transcripts.c.room_id == rooms.c.id, isouter=True
)
query = query.where(transcripts.c.deleted_at.is_(None))
if user_id:
query = query.where(
or_(transcripts.c.user_id == user_id, rooms.c.is_shared)
@@ -500,7 +504,10 @@ class TranscriptController:
"""
Get transcripts by room_id (direct access without joins)
"""
query = transcripts.select().where(transcripts.c.room_id == room_id)
query = transcripts.select().where(
transcripts.c.room_id == room_id,
transcripts.c.deleted_at.is_(None),
)
if "user_id" in kwargs:
query = query.where(transcripts.c.user_id == kwargs["user_id"])
if "order_by" in kwargs:
@@ -531,8 +538,11 @@ class TranscriptController:
if not result:
raise HTTPException(status_code=404, detail="Transcript not found")
# if the transcript is anonymous, share mode is not checked
transcript = Transcript(**result)
if transcript.deleted_at is not None:
raise HTTPException(status_code=404, detail="Transcript not found")
# if the transcript is anonymous, share mode is not checked
if transcript.user_id is None:
return transcript
@@ -632,56 +642,169 @@ class TranscriptController:
user_id: str | None = None,
) -> None:
"""
Remove a transcript by id
Soft-delete a transcript by id.
Sets deleted_at on the transcript and its associated recording.
All files (S3 and local) are preserved for later retrieval.
"""
transcript = await self.get_by_id(transcript_id)
if not transcript:
return
if user_id is not None and transcript.user_id != user_id:
return
if transcript.deleted_at is not None:
return
now = datetime.now(timezone.utc)
# Soft-delete the associated recording (keeps S3 files intact)
if transcript.recording_id:
try:
await recordings_controller.remove_by_id(transcript.recording_id)
except Exception as e:
logger.warning(
"Failed to soft-delete recording",
exc_info=e,
recording_id=transcript.recording_id,
)
# Soft-delete the transcript (keeps all files intact)
query = (
transcripts.update()
.where(transcripts.c.id == transcript_id)
.values(deleted_at=now)
)
await get_database().execute(query)
async def restore_by_id(
self,
transcript_id: str,
user_id: str | None = None,
) -> bool:
"""
Restore a soft-deleted transcript by clearing deleted_at.
Also restores the associated recording if present.
Returns True if the transcript was restored, False otherwise.
"""
transcript = await self.get_by_id(transcript_id)
if not transcript:
return False
if transcript.deleted_at is None:
return False
if user_id is not None and transcript.user_id != user_id:
return False
query = (
transcripts.update()
.where(transcripts.c.id == transcript_id)
.values(deleted_at=None)
)
await get_database().execute(query)
if transcript.recording_id:
try:
await recordings_controller.restore_by_id(transcript.recording_id)
except Exception as e:
logger.warning(
"Failed to restore recording",
exc_info=e,
recording_id=transcript.recording_id,
)
return True
async def hard_delete(self, transcript_id: str) -> None:
"""
Permanently delete a transcript, its recording, and all associated files.
Only deletes transcript-owned resources:
- Transcript row and recording row from DB (first, to make data inaccessible)
- Transcript audio in S3 storage
- Recording files in S3 (both object_key and track_keys, since a recording can have both)
- Local files (data_path directory)
Does NOT delete: meetings, consent records, rooms, or any shared entity.
Requires the transcript to be soft-deleted first (deleted_at must be set).
"""
transcript = await self.get_by_id(transcript_id)
if not transcript:
return
if transcript.deleted_at is None:
return
# Collect file references before deleting DB rows
recording = None
recording_storage = None
if transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
# Determine the correct storage backend for recording files.
# Recordings from different platforms (daily, whereby) live in
# platform-specific buckets with separate credentials.
if recording and recording.meeting_id:
from reflector.db.meetings import meetings_controller # noqa: PLC0415
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if meeting:
recording_storage = get_source_storage(meeting.platform)
if recording_storage is None:
recording_storage = get_transcripts_storage()
# 1. Hard-delete DB rows first (makes data inaccessible immediately)
if recording:
await recordings_controller.hard_delete_by_id(recording.id)
await get_database().execute(
transcripts.delete().where(transcripts.c.id == transcript_id)
)
# 2. Delete transcript audio from S3 (always uses transcript storage)
transcript_storage = get_transcripts_storage()
if transcript.audio_location == "storage" and not transcript.audio_deleted:
try:
await get_transcripts_storage().delete_file(
transcript.storage_audio_path
)
await transcript_storage.delete_file(transcript.storage_audio_path)
except Exception as e:
logger.warning(
"Failed to delete transcript audio from storage",
exc_info=e,
transcript_id=transcript.id,
transcript_id=transcript_id,
path=transcript.storage_audio_path,
)
# 3. Delete recording files from S3 (both object_key and track_keys —
# a recording can have both, unlike consent cleanup which uses elif).
# Uses platform-specific storage resolved above.
if recording and recording.bucket_name and recording_storage:
keys_to_delete = []
if recording.track_keys:
keys_to_delete = recording.track_keys
if recording.object_key:
keys_to_delete.append(recording.object_key)
for key in keys_to_delete:
try:
await recording_storage.delete_file(
key, bucket=recording.bucket_name
)
except Exception as e:
logger.warning(
"Failed to delete recording file",
exc_info=e,
key=key,
bucket=recording.bucket_name,
)
# 4. Delete local files
transcript.unlink()
if transcript.recording_id:
try:
recording = await recordings_controller.get_by_id(
transcript.recording_id
)
if recording:
try:
await get_transcripts_storage().delete_file(
recording.object_key, bucket=recording.bucket_name
)
except Exception as e:
logger.warning(
"Failed to delete recording object from S3",
exc_info=e,
recording_id=transcript.recording_id,
)
await recordings_controller.remove_by_id(transcript.recording_id)
except Exception as e:
logger.warning(
"Failed to delete recording row",
exc_info=e,
recording_id=transcript.recording_id,
)
query = transcripts.delete().where(transcripts.c.id == transcript_id)
await get_database().execute(query)
async def remove_by_recording_id(self, recording_id: str):
"""
Remove a transcript by recording_id
Soft-delete a transcript by recording_id
"""
query = transcripts.delete().where(transcripts.c.recording_id == recording_id)
query = (
transcripts.update()
.where(transcripts.c.recording_id == recording_id)
.values(deleted_at=datetime.now(timezone.utc))
)
await get_database().execute(query)
@staticmethod
@@ -697,6 +820,18 @@ class TranscriptController:
return False
return user_id and transcript.user_id == user_id
@staticmethod
def check_can_mutate(transcript: Transcript, user_id: str | None) -> None:
"""
Raises HTTP 403 if the user cannot mutate the transcript.
Policy:
- Anonymous transcripts (user_id is None) are editable by anyone
- Owned transcripts can only be mutated by their owner
"""
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
@asynccontextmanager
async def transaction(self):
"""

162
server/reflector/email.py Normal file
View File

@@ -0,0 +1,162 @@
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from html import escape
import aiosmtplib
import structlog
from reflector.db.transcripts import SourceKind, Transcript
from reflector.settings import settings
from reflector.utils.transcript_formats import transcript_to_text_timestamped
logger = structlog.get_logger(__name__)
def is_email_configured() -> bool:
return bool(settings.SMTP_HOST and settings.SMTP_FROM_EMAIL)
def get_transcript_url(transcript: Transcript) -> str:
return f"{settings.UI_BASE_URL}/transcripts/{transcript.id}"
def _get_timestamped_text(transcript: Transcript) -> str:
"""Build the full timestamped transcript text using existing utility."""
if not transcript.topics:
return ""
is_multitrack = transcript.source_kind == SourceKind.ROOM
return transcript_to_text_timestamped(
transcript.topics, transcript.participants, is_multitrack=is_multitrack
)
def _build_plain_text(transcript: Transcript, url: str, include_link: bool) -> str:
title = transcript.title or "Unnamed recording"
lines = [f"Reflector: {title}", ""]
if transcript.short_summary:
lines.extend(["Summary:", transcript.short_summary, ""])
timestamped = _get_timestamped_text(transcript)
if timestamped:
lines.extend(["Transcript:", timestamped, ""])
if include_link:
lines.append(f"View transcript: {url}")
lines.append("")
lines.append(
"This email was sent because you requested to receive "
"the transcript from a meeting."
)
return "\n".join(lines)
def _build_html(transcript: Transcript, url: str, include_link: bool) -> str:
title = escape(transcript.title or "Unnamed recording")
summary_html = ""
if transcript.short_summary:
summary_html = (
f'<p style="color:#555;margin-bottom:16px;">'
f"{escape(transcript.short_summary)}</p>"
)
transcript_html = ""
timestamped = _get_timestamped_text(transcript)
if timestamped:
# Build styled transcript lines
styled_lines = []
for line in timestamped.split("\n"):
if not line.strip():
continue
# Lines are formatted as "[MM:SS] Speaker: text"
if line.startswith("[") and "] " in line:
bracket_end = line.index("] ")
timestamp = escape(line[: bracket_end + 1])
rest = line[bracket_end + 2 :]
if ": " in rest:
colon_pos = rest.index(": ")
speaker = escape(rest[:colon_pos])
text = escape(rest[colon_pos + 2 :])
styled_lines.append(
f'<div style="margin-bottom:4px;">'
f'<span style="color:#888;font-size:12px;">{timestamp}</span> '
f"<strong>{speaker}:</strong> {text}</div>"
)
else:
styled_lines.append(
f'<div style="margin-bottom:4px;">{escape(line)}</div>'
)
else:
styled_lines.append(
f'<div style="margin-bottom:4px;">{escape(line)}</div>'
)
transcript_html = (
'<h3 style="margin-top:20px;margin-bottom:8px;">Transcript</h3>'
'<div style="background:#f7f7f7;padding:16px;border-radius:6px;'
'font-size:13px;line-height:1.6;max-height:600px;overflow-y:auto;">'
f"{''.join(styled_lines)}</div>"
)
link_html = ""
if include_link:
link_html = (
'<p style="margin-top:20px;">'
f'<a href="{url}" style="display:inline-block;padding:10px 20px;'
"background:#4A90D9;color:#fff;text-decoration:none;"
'border-radius:4px;">View Transcript</a></p>'
)
return f"""\
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<h2 style="margin-bottom:4px;">{title}</h2>
{summary_html}
{transcript_html}
{link_html}
<p style="color:#999;font-size:12px;margin-top:20px;">This email was sent because you requested to receive the transcript from a meeting.</p>
</div>"""
async def send_transcript_email(
to_emails: list[str],
transcript: Transcript,
*,
include_link: bool = True,
) -> int:
"""Send transcript notification to all emails. Returns count sent."""
if not is_email_configured() or not to_emails:
return 0
url = get_transcript_url(transcript)
title = transcript.title or "Unnamed recording"
sent = 0
for email_addr in to_emails:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"Reflector: {title}"
msg["From"] = settings.SMTP_FROM_EMAIL
msg["To"] = email_addr
msg.attach(MIMEText(_build_plain_text(transcript, url, include_link), "plain"))
msg.attach(MIMEText(_build_html(transcript, url, include_link), "html"))
try:
await aiosmtplib.send(
msg,
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
username=settings.SMTP_USERNAME,
password=settings.SMTP_PASSWORD,
start_tls=settings.SMTP_USE_TLS,
)
sent += 1
except Exception:
logger.exception(
"Failed to send transcript email",
to=email_addr,
transcript_id=transcript.id,
)
return sent

View File

@@ -21,11 +21,27 @@ class TaskName(StrEnum):
CLEANUP_CONSENT = "cleanup_consent"
POST_ZULIP = "post_zulip"
SEND_WEBHOOK = "send_webhook"
SEND_EMAIL = "send_email"
PAD_TRACK = "pad_track"
TRANSCRIBE_TRACK = "transcribe_track"
DETECT_CHUNK_TOPIC = "detect_chunk_topic"
GENERATE_DETAILED_SUMMARY = "generate_detailed_summary"
# File pipeline tasks
EXTRACT_AUDIO = "extract_audio"
UPLOAD_AUDIO = "upload_audio"
TRANSCRIBE = "transcribe"
DIARIZE = "diarize"
ASSEMBLE_TRANSCRIPT = "assemble_transcript"
GENERATE_SUMMARIES = "generate_summaries"
# Live post-processing pipeline tasks
WAVEFORM = "waveform"
CONVERT_MP3 = "convert_mp3"
UPLOAD_MP3 = "upload_mp3"
REMOVE_UPLOAD = "remove_upload"
FINAL_SUMMARIES = "final_summaries"
# Rate limit key for LLM API calls (shared across all LLM-calling tasks)
LLM_RATE_LIMIT_KEY = "llm"
@@ -44,7 +60,13 @@ TIMEOUT_AUDIO = 720 # Audio processing: padding, mixdown (Hatchet execution_tim
TIMEOUT_AUDIO_HTTP = (
660 # httpx timeout for pad_track — below 720 so Hatchet doesn't race
)
TIMEOUT_HEAVY = 600 # Transcription, fan-out LLM tasks (Hatchet execution_timeout)
TIMEOUT_HEAVY = 1200 # Transcription, fan-out LLM tasks (Hatchet execution_timeout)
TIMEOUT_HEAVY_HTTP = (
540 # httpx timeout for transcribe_track — below 600 so Hatchet doesn't race
1150 # httpx timeout for transcribe_track — below 1200 so Hatchet doesn't race
)
TIMEOUT_EXTRA_HEAVY = (
3600 # Detect Topics, fan-out LLM tasks (Hatchet execution_timeout)
)
TIMEOUT_EXTRA_HEAVY_HTTP = (
3400 # httpx timeout for detect_topics — below 3600 so Hatchet doesn't race
)

View File

@@ -10,10 +10,13 @@ from reflector.hatchet.client import HatchetClientManager
from reflector.hatchet.workflows.daily_multitrack_pipeline import (
daily_multitrack_pipeline,
)
from reflector.hatchet.workflows.file_pipeline import file_pipeline
from reflector.hatchet.workflows.live_post_pipeline import live_post_pipeline
from reflector.hatchet.workflows.subject_processing import subject_workflow
from reflector.hatchet.workflows.topic_chunk_processing import topic_chunk_workflow
from reflector.hatchet.workflows.track_processing import track_workflow
from reflector.logger import logger
from reflector.settings import settings
SLOTS = 10
WORKER_NAME = "llm-worker-pool"
@@ -32,6 +35,38 @@ def main():
error=str(e),
)
workflows = [
daily_multitrack_pipeline,
file_pipeline,
live_post_pipeline,
topic_chunk_workflow,
subject_workflow,
track_workflow,
]
_zulip_dag_enabled = all(
[
settings.ZULIP_REALM,
settings.ZULIP_API_KEY,
settings.ZULIP_BOT_EMAIL,
settings.ZULIP_DAG_STREAM,
settings.ZULIP_DAG_TOPIC,
]
)
if _zulip_dag_enabled:
from reflector.hatchet.workflows.failed_runs_monitor import ( # noqa: PLC0415
failed_runs_monitor,
)
workflows.append(failed_runs_monitor)
logger.info(
"FailedRunsMonitor cron enabled",
stream=settings.ZULIP_DAG_STREAM,
topic=settings.ZULIP_DAG_TOPIC,
)
else:
logger.info("FailedRunsMonitor cron disabled (Zulip DAG not configured)")
logger.info(
"Starting Hatchet LLM worker pool (all tasks except mixdown)",
worker_name=WORKER_NAME,
@@ -45,12 +80,7 @@ def main():
labels={
"pool": POOL,
},
workflows=[
daily_multitrack_pipeline,
topic_chunk_workflow,
subject_workflow,
track_workflow,
],
workflows=workflows,
)
try:

View File

@@ -33,6 +33,7 @@ from hatchet_sdk.labels import DesiredWorkerLabel
from pydantic import BaseModel
from reflector.dailyco_api.client import DailyApiClient
from reflector.email import is_email_configured, send_transcript_email
from reflector.hatchet.broadcast import (
append_event_and_broadcast,
set_status_and_broadcast,
@@ -40,6 +41,7 @@ from reflector.hatchet.broadcast import (
from reflector.hatchet.client import HatchetClientManager
from reflector.hatchet.constants import (
TIMEOUT_AUDIO,
TIMEOUT_EXTRA_HEAVY,
TIMEOUT_HEAVY,
TIMEOUT_LONG,
TIMEOUT_MEDIUM,
@@ -51,6 +53,7 @@ from reflector.hatchet.error_classification import is_non_retryable
from reflector.hatchet.workflows.models import (
ActionItemsResult,
ConsentResult,
EmailResult,
FinalizeResult,
MixdownResult,
PaddedTrackInfo,
@@ -82,7 +85,7 @@ from reflector.hatchet.workflows.topic_chunk_processing import (
from reflector.hatchet.workflows.track_processing import TrackInput, track_workflow
from reflector.logger import logger
from reflector.pipelines import topic_processing
from reflector.processors import AudioFileWriterProcessor
from reflector.processors.audio_mixdown_auto import AudioMixdownAutoProcessor
from reflector.processors.summary.models import ActionItemsResponse
from reflector.processors.summary.prompts import (
RECAP_PROMPT,
@@ -97,10 +100,6 @@ from reflector.utils.audio_constants import (
PRESIGNED_URL_EXPIRATION_SECONDS,
WAVEFORM_SEGMENTS,
)
from reflector.utils.audio_mixdown import (
detect_sample_rate_from_tracks,
mixdown_tracks_pyav,
)
from reflector.utils.audio_waveform import get_audio_waveform
from reflector.utils.daily import (
filter_cam_audio_tracks,
@@ -307,7 +306,9 @@ async def get_recording(input: PipelineInput, ctx: Context) -> RecordingResult:
ctx.log(
f"get_recording: calling Daily.co API for recording_id={input.recording_id}..."
)
async with DailyApiClient(api_key=settings.DAILY_API_KEY) as client:
async with DailyApiClient(
api_key=settings.DAILY_API_KEY, base_url=settings.DAILY_API_URL
) as client:
recording = await client.get_recording(input.recording_id)
ctx.log(f"get_recording: Daily.co API returned successfully")
@@ -374,7 +375,9 @@ async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsRe
settings.DAILY_API_KEY, "DAILY_API_KEY is required"
)
async with DailyApiClient(api_key=daily_api_key) as client:
async with DailyApiClient(
api_key=daily_api_key, base_url=settings.DAILY_API_URL
) as client:
participants = await client.get_meeting_participants(mtg_session_id)
id_to_name = {}
@@ -533,7 +536,7 @@ async def process_tracks(input: PipelineInput, ctx: Context) -> ProcessTracksRes
)
@with_error_handling(TaskName.MIXDOWN_TRACKS)
async def mixdown_tracks(input: PipelineInput, ctx: Context) -> MixdownResult:
"""Mix all padded tracks into single audio file using PyAV (same as Celery)."""
"""Mix all padded tracks into single audio file via configured backend."""
ctx.log("mixdown_tracks: mixing padded tracks into single audio file")
track_result = ctx.task_output(process_tracks)
@@ -573,37 +576,33 @@ async def mixdown_tracks(input: PipelineInput, ctx: Context) -> MixdownResult:
if not valid_urls:
raise ValueError("No valid padded tracks to mixdown")
target_sample_rate = detect_sample_rate_from_tracks(valid_urls, logger=logger)
if not target_sample_rate:
logger.error("Mixdown failed - no decodable audio frames found")
raise ValueError("No decodable audio frames in any track")
output_path = tempfile.mktemp(suffix=".mp3")
duration_ms_callback_capture_container = [0.0]
async def capture_duration(d):
duration_ms_callback_capture_container[0] = d
writer = AudioFileWriterProcessor(path=output_path, on_duration=capture_duration)
await mixdown_tracks_pyav(
valid_urls,
writer,
target_sample_rate,
offsets_seconds=None,
logger=logger,
progress_callback=make_audio_progress_logger(ctx, TaskName.MIXDOWN_TRACKS),
expected_duration_sec=recording_duration if recording_duration > 0 else None,
)
await writer.flush()
file_size = Path(output_path).stat().st_size
storage_path = f"{input.transcript_id}/audio.mp3"
with open(output_path, "rb") as mixed_file:
await storage.put_file(storage_path, mixed_file)
# Generate presigned PUT URL for the output (used by modal backend;
# pyav backend ignores it and writes locally instead)
output_url = await storage.get_file_url(
storage_path,
operation="put_object",
expires_in=PRESIGNED_URL_EXPIRATION_SECONDS,
)
Path(output_path).unlink(missing_ok=True)
processor = AudioMixdownAutoProcessor()
result = await processor.mixdown_tracks(
valid_urls, output_url, offsets_seconds=None
)
if result.output_path:
# Pyav backend wrote locally — upload to storage ourselves
output_file = Path(result.output_path)
with open(output_file, "rb") as mixed_file:
await storage.put_file(storage_path, mixed_file)
output_file.unlink(missing_ok=True)
# Clean up the temp directory the pyav processor created
try:
output_file.parent.rmdir()
except OSError:
pass
# else: modal backend already uploaded to output_url
async with fresh_db_connection():
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
@@ -614,11 +613,11 @@ async def mixdown_tracks(input: PipelineInput, ctx: Context) -> MixdownResult:
transcript, {"audio_location": "storage"}
)
ctx.log(f"mixdown_tracks complete: uploaded {file_size} bytes to {storage_path}")
ctx.log(f"mixdown_tracks complete: {result.size} bytes to {storage_path}")
return MixdownResult(
audio_key=storage_path,
duration=duration_ms_callback_capture_container[0],
duration=result.duration_ms,
tracks_mixed=len(valid_urls),
)
@@ -695,7 +694,7 @@ async def generate_waveform(input: PipelineInput, ctx: Context) -> WaveformResul
@daily_multitrack_pipeline.task(
parents=[process_tracks],
execution_timeout=timedelta(seconds=TIMEOUT_HEAVY),
execution_timeout=timedelta(seconds=TIMEOUT_EXTRA_HEAVY),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=30,
@@ -1279,6 +1278,7 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
return ConsentResult()
consent_denied = False
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if meeting:
@@ -1341,6 +1341,22 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
# Delete cloud video if present
if meeting and meeting.daily_composed_video_s3_key:
try:
source_storage = get_source_storage("daily")
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
await meetings_controller.update_meeting(
meeting.id,
daily_composed_video_s3_key=None,
daily_composed_video_duration=None,
)
ctx.log(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
except Exception as e:
error_msg = f"Failed to delete cloud video: {e}"
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
if deletion_errors:
logger.warning(
"[Hatchet] cleanup_consent completed with errors",
@@ -1351,7 +1367,7 @@ async def cleanup_consent(input: PipelineInput, ctx: Context) -> ConsentResult:
ctx.log(f"cleanup_consent completed with {len(deletion_errors)} errors")
else:
await transcripts_controller.update(transcript, {"audio_deleted": True})
ctx.log("cleanup_consent: all audio deleted successfully")
ctx.log("cleanup_consent: all audio and video deleted successfully")
return ConsentResult()
@@ -1461,6 +1477,96 @@ async def send_webhook(input: PipelineInput, ctx: Context) -> WebhookResult:
return WebhookResult(webhook_sent=False)
@daily_multitrack_pipeline.task(
parents=[cleanup_consent],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.SEND_EMAIL, set_error_status=False)
async def send_email(input: PipelineInput, ctx: Context) -> EmailResult:
"""Send transcript email to collected recipients."""
ctx.log(f"send_email: transcript_id={input.transcript_id}")
if not is_email_configured():
ctx.log("send_email skipped (SMTP not configured)")
return EmailResult(skipped=True)
async with fresh_db_connection():
from reflector.db.meetings import meetings_controller # noqa: PLC0415
from reflector.db.recordings import recordings_controller # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
ctx.log("send_email skipped (transcript not found)")
return EmailResult(skipped=True)
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if not meeting and transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
# Normalise meeting recipients (legacy strings → dicts)
meeting_recipients: list[dict] = (
[
entry
if isinstance(entry, dict)
else {"email": entry, "include_link": True}
for entry in (meeting.email_recipients or [])
]
if meeting and meeting.email_recipients
else []
)
# Room-level email always gets a link (room owner)
from reflector.db.rooms import rooms_controller # noqa: PLC0415
room_email = None
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
room_email = room.email_transcript_to
# Build two groups: with link and without link
with_link = [
r["email"] for r in meeting_recipients if r.get("include_link", True)
]
without_link = [
r["email"] for r in meeting_recipients if not r.get("include_link", True)
]
if room_email:
if room_email not in with_link:
with_link.append(room_email)
without_link = [e for e in without_link if e != room_email]
if not with_link and not without_link:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = 0
if with_link:
count += await send_transcript_email(
with_link, transcript, include_link=True
)
if without_link:
count += await send_transcript_email(
without_link, transcript, include_link=False
)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
async def on_workflow_failure(input: PipelineInput, ctx: Context) -> None:
"""Run when the workflow is truly dead (all retries exhausted).

View File

@@ -0,0 +1,109 @@
"""
Hatchet cron workflow: FailedRunsMonitor
Runs hourly, queries Hatchet for failed pipeline runs in the last hour,
and posts details to Zulip for visibility.
Only registered with the worker when Zulip DAG settings are configured.
"""
from datetime import datetime, timedelta, timezone
from hatchet_sdk import Context
from hatchet_sdk.clients.rest.models import V1TaskStatus
from reflector.hatchet.client import HatchetClientManager
from reflector.logger import logger
from reflector.settings import settings
from reflector.tools.render_hatchet_run import render_run_detail
from reflector.zulip import send_message_to_zulip
MONITORED_PIPELINES = {
"DiarizationPipeline",
"FilePipeline",
"LivePostProcessingPipeline",
}
LOOKBACK_HOURS = 1
hatchet = HatchetClientManager.get_client()
failed_runs_monitor = hatchet.workflow(
name="FailedRunsMonitor",
on_crons=["0 * * * *"],
)
async def _check_failed_runs() -> dict:
"""Core logic: query for failed pipeline runs and post each to Zulip.
Extracted from the Hatchet task for testability.
"""
now = datetime.now(tz=timezone.utc)
since = now - timedelta(hours=LOOKBACK_HOURS)
client = HatchetClientManager.get_client()
try:
result = await client.runs.aio_list(
statuses=[V1TaskStatus.FAILED],
since=since,
until=now,
limit=200,
)
except Exception:
logger.exception("[FailedRunsMonitor] Failed to list runs from Hatchet")
return {"checked": 0, "reported": 0, "error": "failed to list runs"}
rows = result.rows or []
# Filter to main pipelines only (skip child workflows like TrackProcessing, etc.)
failed_main_runs = [run for run in rows if run.workflow_name in MONITORED_PIPELINES]
if not failed_main_runs:
logger.info(
"[FailedRunsMonitor] No failed pipeline runs in the last hour",
total_failed=len(rows),
since=since.isoformat(),
)
return {"checked": len(rows), "reported": 0}
logger.info(
"[FailedRunsMonitor] Found failed pipeline runs",
count=len(failed_main_runs),
since=since.isoformat(),
)
reported = 0
for run in failed_main_runs:
try:
details = await client.runs.aio_get(run.workflow_run_external_id)
content = render_run_detail(details)
await send_message_to_zulip(
settings.ZULIP_DAG_STREAM,
settings.ZULIP_DAG_TOPIC,
content,
)
reported += 1
except Exception:
logger.exception(
"[FailedRunsMonitor] Failed to report run",
workflow_run_id=run.workflow_run_external_id,
workflow_name=run.workflow_name,
)
logger.info(
"[FailedRunsMonitor] Finished reporting",
reported=reported,
total_failed_main=len(failed_main_runs),
)
return {"checked": len(rows), "reported": reported}
@failed_runs_monitor.task(
execution_timeout=timedelta(seconds=120),
retries=1,
)
async def check_failed_runs(input, ctx: Context) -> dict:
"""Hatchet task entry point — delegates to _check_failed_runs."""
return await _check_failed_runs()

View File

@@ -0,0 +1,998 @@
"""
Hatchet workflow: FilePipeline
Processing pipeline for file uploads and Whereby recordings.
Orchestrates: extract audio → upload → transcribe/diarize/waveform (parallel)
→ assemble → detect topics → title/summaries (parallel) → finalize
→ cleanup consent → post zulip / send webhook.
Note: This file uses deferred imports (inside functions/tasks) intentionally.
Hatchet workers run in forked processes; fresh imports per task ensure DB connections
are not shared across forks, avoiding connection pooling issues.
"""
import json
from datetime import timedelta
from pathlib import Path
from hatchet_sdk import Context
from pydantic import BaseModel
from reflector.email import is_email_configured, send_transcript_email
from reflector.hatchet.broadcast import (
append_event_and_broadcast,
set_status_and_broadcast,
)
from reflector.hatchet.client import HatchetClientManager
from reflector.hatchet.constants import (
TIMEOUT_HEAVY,
TIMEOUT_MEDIUM,
TIMEOUT_SHORT,
TIMEOUT_TITLE,
TaskName,
)
from reflector.hatchet.workflows.daily_multitrack_pipeline import (
fresh_db_connection,
set_workflow_error_status,
with_error_handling,
)
from reflector.hatchet.workflows.models import (
ConsentResult,
EmailResult,
TitleResult,
TopicsResult,
WaveformResult,
WebhookResult,
ZulipResult,
)
from reflector.logger import logger
from reflector.pipelines import topic_processing
from reflector.settings import settings
from reflector.utils.audio_constants import WAVEFORM_SEGMENTS
from reflector.utils.audio_waveform import get_audio_waveform
class FilePipelineInput(BaseModel):
transcript_id: str
room_id: str | None = None
# --- Result models specific to file pipeline ---
class ExtractAudioResult(BaseModel):
audio_path: str
duration_ms: float = 0.0
class UploadAudioResult(BaseModel):
audio_url: str
audio_path: str
class TranscribeResult(BaseModel):
words: list[dict]
translation: str | None = None
class DiarizeResult(BaseModel):
diarization: list[dict] | None = None
class AssembleTranscriptResult(BaseModel):
assembled: bool
class SummariesResult(BaseModel):
generated: bool
class FinalizeResult(BaseModel):
status: str
hatchet = HatchetClientManager.get_client()
file_pipeline = hatchet.workflow(name="FilePipeline", input_validator=FilePipelineInput)
@file_pipeline.task(
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.EXTRACT_AUDIO)
async def extract_audio(input: FilePipelineInput, ctx: Context) -> ExtractAudioResult:
"""Extract audio from upload file, convert to MP3."""
ctx.log(f"extract_audio: starting for transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
await set_status_and_broadcast(input.transcript_id, "processing", logger=logger)
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
raise ValueError(f"Transcript {input.transcript_id} not found")
# Clear transcript as we're going to regenerate everything
await transcripts_controller.update(
transcript,
{
"events": [],
"topics": [],
},
)
# Find upload file
audio_file = next(transcript.data_path.glob("upload.*"), None)
if not audio_file:
audio_file = next(transcript.data_path.glob("audio.*"), None)
if not audio_file:
raise ValueError("No audio file found to process")
ctx.log(f"extract_audio: processing {audio_file}")
# Extract audio and write as MP3
import av # noqa: PLC0415
from reflector.processors import AudioFileWriterProcessor # noqa: PLC0415
duration_ms_container = [0.0]
async def capture_duration(d):
duration_ms_container[0] = d
mp3_writer = AudioFileWriterProcessor(
path=transcript.audio_mp3_filename,
on_duration=capture_duration,
)
input_container = av.open(str(audio_file))
for frame in input_container.decode(audio=0):
await mp3_writer.push(frame)
await mp3_writer.flush()
input_container.close()
duration_ms = duration_ms_container[0]
audio_path = str(transcript.audio_mp3_filename)
# Persist duration to database and broadcast to websocket clients
from reflector.db.transcripts import TranscriptDuration # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller as tc
await tc.update(transcript, {"duration": duration_ms})
await append_event_and_broadcast(
input.transcript_id,
transcript,
"DURATION",
TranscriptDuration(duration=duration_ms),
logger=logger,
)
ctx.log(f"extract_audio complete: {audio_path}, duration={duration_ms}ms")
return ExtractAudioResult(audio_path=audio_path, duration_ms=duration_ms)
@file_pipeline.task(
parents=[extract_audio],
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.UPLOAD_AUDIO)
async def upload_audio(input: FilePipelineInput, ctx: Context) -> UploadAudioResult:
"""Upload audio to S3/storage, return audio_url."""
ctx.log(f"upload_audio: starting for transcript_id={input.transcript_id}")
extract_result = ctx.task_output(extract_audio)
audio_path = extract_result.audio_path
from reflector.storage import get_transcripts_storage # noqa: PLC0415
storage = get_transcripts_storage()
if not storage:
raise ValueError(
"Storage backend required for file processing. "
"Configure TRANSCRIPT_STORAGE_* settings."
)
with open(audio_path, "rb") as f:
audio_data = f.read()
storage_path = f"file_pipeline/{input.transcript_id}/audio.mp3"
await storage.put_file(storage_path, audio_data)
audio_url = await storage.get_file_url(storage_path)
ctx.log(f"upload_audio complete: {audio_url}")
return UploadAudioResult(audio_url=audio_url, audio_path=audio_path)
@file_pipeline.task(
parents=[upload_audio],
execution_timeout=timedelta(seconds=TIMEOUT_HEAVY),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=30,
)
@with_error_handling(TaskName.TRANSCRIBE)
async def transcribe(input: FilePipelineInput, ctx: Context) -> TranscribeResult:
"""Transcribe the audio file using the configured backend."""
ctx.log(f"transcribe: starting for transcript_id={input.transcript_id}")
upload_result = ctx.task_output(upload_audio)
audio_url = upload_result.audio_url
async with fresh_db_connection():
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
raise ValueError(f"Transcript {input.transcript_id} not found")
source_language = transcript.source_language
from reflector.pipelines.transcription_helpers import ( # noqa: PLC0415
transcribe_file_with_processor,
)
result = await transcribe_file_with_processor(audio_url, source_language)
ctx.log(f"transcribe complete: {len(result.words)} words")
return TranscribeResult(
words=[w.model_dump() for w in result.words],
translation=result.translation,
)
@file_pipeline.task(
parents=[upload_audio],
execution_timeout=timedelta(seconds=TIMEOUT_HEAVY),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=30,
)
@with_error_handling(TaskName.DIARIZE)
async def diarize(input: FilePipelineInput, ctx: Context) -> DiarizeResult:
"""Diarize the audio file (speaker identification)."""
ctx.log(f"diarize: starting for transcript_id={input.transcript_id}")
if not settings.DIARIZATION_BACKEND:
ctx.log("diarize: diarization disabled, skipping")
return DiarizeResult(diarization=None)
upload_result = ctx.task_output(upload_audio)
audio_url = upload_result.audio_url
from reflector.processors.file_diarization import ( # noqa: PLC0415
FileDiarizationInput,
)
from reflector.processors.file_diarization_auto import ( # noqa: PLC0415
FileDiarizationAutoProcessor,
)
processor = FileDiarizationAutoProcessor()
input_data = FileDiarizationInput(audio_url=audio_url)
result = None
async def capture_result(diarization_output):
nonlocal result
result = diarization_output.diarization
try:
processor.on(capture_result)
await processor.push(input_data)
await processor.flush()
except Exception as e:
logger.error(f"Diarization failed: {e}")
return DiarizeResult(diarization=None)
ctx.log(f"diarize complete: {len(result) if result else 0} segments")
return DiarizeResult(diarization=list(result) if result else None)
@file_pipeline.task(
parents=[upload_audio],
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.GENERATE_WAVEFORM)
async def generate_waveform(input: FilePipelineInput, ctx: Context) -> WaveformResult:
"""Generate audio waveform visualization."""
ctx.log(f"generate_waveform: starting for transcript_id={input.transcript_id}")
upload_result = ctx.task_output(upload_audio)
audio_path = upload_result.audio_path
from reflector.db.transcripts import ( # noqa: PLC0415
TranscriptWaveform,
transcripts_controller,
)
waveform = get_audio_waveform(
path=Path(audio_path), segments_count=WAVEFORM_SEGMENTS
)
async with fresh_db_connection():
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if transcript:
transcript.data_path.mkdir(parents=True, exist_ok=True)
with open(transcript.audio_waveform_filename, "w") as f:
json.dump(waveform, f)
waveform_data = TranscriptWaveform(waveform=waveform)
await append_event_and_broadcast(
input.transcript_id,
transcript,
"WAVEFORM",
waveform_data,
logger=logger,
)
ctx.log("generate_waveform complete")
return WaveformResult(waveform_generated=True)
@file_pipeline.task(
parents=[transcribe, diarize, generate_waveform],
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.ASSEMBLE_TRANSCRIPT)
async def assemble_transcript(
input: FilePipelineInput, ctx: Context
) -> AssembleTranscriptResult:
"""Merge transcription + diarization results."""
ctx.log(f"assemble_transcript: starting for transcript_id={input.transcript_id}")
transcribe_result = ctx.task_output(transcribe)
diarize_result = ctx.task_output(diarize)
from reflector.processors.transcript_diarization_assembler import ( # noqa: PLC0415
TranscriptDiarizationAssemblerInput,
TranscriptDiarizationAssemblerProcessor,
)
from reflector.processors.types import ( # noqa: PLC0415
DiarizationSegment,
Word,
)
from reflector.processors.types import ( # noqa: PLC0415
Transcript as TranscriptType,
)
words = [Word(**w) for w in transcribe_result.words]
transcript_data = TranscriptType(
words=words, translation=transcribe_result.translation
)
diarization = None
if diarize_result.diarization:
diarization = [DiarizationSegment(**s) for s in diarize_result.diarization]
processor = TranscriptDiarizationAssemblerProcessor()
assembler_input = TranscriptDiarizationAssemblerInput(
transcript=transcript_data, diarization=diarization or []
)
diarized_transcript = None
async def capture_result(transcript):
nonlocal diarized_transcript
diarized_transcript = transcript
processor.on(capture_result)
await processor.push(assembler_input)
await processor.flush()
if not diarized_transcript:
raise ValueError("No diarized transcript captured")
# Save the assembled transcript events to the database
async with fresh_db_connection():
from reflector.db.transcripts import ( # noqa: PLC0415
TranscriptText,
transcripts_controller,
)
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if transcript:
assembled_text = diarized_transcript.text if diarized_transcript else ""
assembled_translation = (
diarized_transcript.translation if diarized_transcript else None
)
await append_event_and_broadcast(
input.transcript_id,
transcript,
"TRANSCRIPT",
TranscriptText(text=assembled_text, translation=assembled_translation),
logger=logger,
)
ctx.log("assemble_transcript complete")
return AssembleTranscriptResult(assembled=True)
@file_pipeline.task(
parents=[assemble_transcript],
execution_timeout=timedelta(seconds=TIMEOUT_HEAVY),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=30,
)
@with_error_handling(TaskName.DETECT_TOPICS)
async def detect_topics(input: FilePipelineInput, ctx: Context) -> TopicsResult:
"""Detect topics from the assembled transcript."""
ctx.log(f"detect_topics: starting for transcript_id={input.transcript_id}")
# Re-read the transcript to get the diarized words
transcribe_result = ctx.task_output(transcribe)
diarize_result = ctx.task_output(diarize)
from reflector.db.transcripts import ( # noqa: PLC0415
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.transcript_diarization_assembler import ( # noqa: PLC0415
TranscriptDiarizationAssemblerInput,
TranscriptDiarizationAssemblerProcessor,
)
from reflector.processors.types import ( # noqa: PLC0415
DiarizationSegment,
Word,
)
from reflector.processors.types import ( # noqa: PLC0415
Transcript as TranscriptType,
)
words = [Word(**w) for w in transcribe_result.words]
transcript_data = TranscriptType(
words=words, translation=transcribe_result.translation
)
diarization = None
if diarize_result.diarization:
diarization = [DiarizationSegment(**s) for s in diarize_result.diarization]
# Re-assemble to get the diarized transcript for topic detection
processor = TranscriptDiarizationAssemblerProcessor()
assembler_input = TranscriptDiarizationAssemblerInput(
transcript=transcript_data, diarization=diarization or []
)
diarized_transcript = None
async def capture_result(transcript):
nonlocal diarized_transcript
diarized_transcript = transcript
processor.on(capture_result)
await processor.push(assembler_input)
await processor.flush()
if not diarized_transcript:
raise ValueError("No diarized transcript for topic detection")
async with fresh_db_connection():
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
raise ValueError(f"Transcript {input.transcript_id} not found")
target_language = transcript.target_language
empty_pipeline = topic_processing.EmptyPipeline(logger=logger)
async def on_topic_callback(data):
topic = TranscriptTopic(
title=data.title,
summary=data.summary,
timestamp=data.timestamp,
transcript=data.transcript.text
if hasattr(data.transcript, "text")
else "",
words=data.transcript.words
if hasattr(data.transcript, "words")
else [],
)
await transcripts_controller.upsert_topic(transcript, topic)
await append_event_and_broadcast(
input.transcript_id, transcript, "TOPIC", topic, logger=logger
)
topics = await topic_processing.detect_topics(
diarized_transcript,
target_language,
on_topic_callback=on_topic_callback,
empty_pipeline=empty_pipeline,
)
ctx.log(f"detect_topics complete: {len(topics)} topics")
return TopicsResult(topics=topics)
@file_pipeline.task(
parents=[detect_topics],
execution_timeout=timedelta(seconds=TIMEOUT_TITLE),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.GENERATE_TITLE)
async def generate_title(input: FilePipelineInput, ctx: Context) -> TitleResult:
"""Generate meeting title using LLM."""
ctx.log(f"generate_title: starting for transcript_id={input.transcript_id}")
topics_result = ctx.task_output(detect_topics)
topics = topics_result.topics
from reflector.db.transcripts import ( # noqa: PLC0415
TranscriptFinalTitle,
transcripts_controller,
)
empty_pipeline = topic_processing.EmptyPipeline(logger=logger)
title_result = None
async with fresh_db_connection():
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
raise ValueError(f"Transcript {input.transcript_id} not found")
async def on_title_callback(data):
nonlocal title_result
title_result = data.title
final_title = TranscriptFinalTitle(title=data.title)
if not transcript.title:
await transcripts_controller.update(
transcript, {"title": final_title.title}
)
await append_event_and_broadcast(
input.transcript_id,
transcript,
"FINAL_TITLE",
final_title,
logger=logger,
)
await topic_processing.generate_title(
topics,
on_title_callback=on_title_callback,
empty_pipeline=empty_pipeline,
logger=logger,
)
ctx.log(f"generate_title complete: '{title_result}'")
return TitleResult(title=title_result)
@file_pipeline.task(
parents=[detect_topics],
execution_timeout=timedelta(seconds=TIMEOUT_HEAVY),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=30,
)
@with_error_handling(TaskName.GENERATE_SUMMARIES)
async def generate_summaries(input: FilePipelineInput, ctx: Context) -> SummariesResult:
"""Generate long/short summaries and action items."""
ctx.log(f"generate_summaries: starting for transcript_id={input.transcript_id}")
topics_result = ctx.task_output(detect_topics)
topics = topics_result.topics
from reflector.db.transcripts import ( # noqa: PLC0415
TranscriptActionItems,
TranscriptFinalLongSummary,
TranscriptFinalShortSummary,
transcripts_controller,
)
empty_pipeline = topic_processing.EmptyPipeline(logger=logger)
async with fresh_db_connection():
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
raise ValueError(f"Transcript {input.transcript_id} not found")
async def on_long_summary_callback(data):
final_long = TranscriptFinalLongSummary(long_summary=data.long_summary)
await transcripts_controller.update(
transcript, {"long_summary": final_long.long_summary}
)
await append_event_and_broadcast(
input.transcript_id,
transcript,
"FINAL_LONG_SUMMARY",
final_long,
logger=logger,
)
async def on_short_summary_callback(data):
final_short = TranscriptFinalShortSummary(short_summary=data.short_summary)
await transcripts_controller.update(
transcript, {"short_summary": final_short.short_summary}
)
await append_event_and_broadcast(
input.transcript_id,
transcript,
"FINAL_SHORT_SUMMARY",
final_short,
logger=logger,
)
async def on_action_items_callback(data):
action_items = TranscriptActionItems(action_items=data.action_items)
await transcripts_controller.update(
transcript, {"action_items": action_items.action_items}
)
await append_event_and_broadcast(
input.transcript_id,
transcript,
"ACTION_ITEMS",
action_items,
logger=logger,
)
await topic_processing.generate_summaries(
topics,
transcript,
on_long_summary_callback=on_long_summary_callback,
on_short_summary_callback=on_short_summary_callback,
on_action_items_callback=on_action_items_callback,
empty_pipeline=empty_pipeline,
logger=logger,
)
ctx.log("generate_summaries complete")
return SummariesResult(generated=True)
@file_pipeline.task(
parents=[generate_title, generate_summaries],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=5,
)
@with_error_handling(TaskName.FINALIZE)
async def finalize(input: FilePipelineInput, ctx: Context) -> FinalizeResult:
"""Set transcript status to 'ended' and broadcast."""
ctx.log("finalize: setting status to 'ended'")
async with fresh_db_connection():
await set_status_and_broadcast(input.transcript_id, "ended", logger=logger)
ctx.log("finalize complete")
return FinalizeResult(status="COMPLETED")
@file_pipeline.task(
parents=[finalize],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.CLEANUP_CONSENT, set_error_status=False)
async def cleanup_consent(input: FilePipelineInput, ctx: Context) -> ConsentResult:
"""Check consent and delete audio files if any participant denied."""
ctx.log(f"cleanup_consent: transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.db.meetings import ( # noqa: PLC0415
meeting_consent_controller,
meetings_controller,
)
from reflector.db.recordings import recordings_controller # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
from reflector.storage import ( # noqa: PLC0415
get_source_storage,
get_transcripts_storage,
)
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
ctx.log("cleanup_consent: transcript not found")
return ConsentResult()
consent_denied = False
recording = None
meeting = None
if transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if meeting:
consent_denied = await meeting_consent_controller.has_any_denial(
meeting.id
)
if not consent_denied:
ctx.log("cleanup_consent: consent approved, keeping all files")
return ConsentResult()
ctx.log("cleanup_consent: consent denied, deleting audio files")
deletion_errors = []
if recording and recording.bucket_name:
keys_to_delete = []
if recording.track_keys:
keys_to_delete = recording.track_keys
elif recording.object_key:
keys_to_delete = [recording.object_key]
master_storage = get_transcripts_storage()
for key in keys_to_delete:
try:
await master_storage.delete_file(key, bucket=recording.bucket_name)
ctx.log(f"Deleted recording file: {recording.bucket_name}/{key}")
except Exception as e:
error_msg = f"Failed to delete {key}: {e}"
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
if transcript.audio_location == "storage":
storage = get_transcripts_storage()
try:
await storage.delete_file(transcript.storage_audio_path)
ctx.log(f"Deleted processed audio: {transcript.storage_audio_path}")
except Exception as e:
error_msg = f"Failed to delete processed audio: {e}"
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
try:
if (
hasattr(transcript, "audio_mp3_filename")
and transcript.audio_mp3_filename
):
transcript.audio_mp3_filename.unlink(missing_ok=True)
if (
hasattr(transcript, "audio_wav_filename")
and transcript.audio_wav_filename
):
transcript.audio_wav_filename.unlink(missing_ok=True)
except Exception as e:
error_msg = f"Failed to delete local audio files: {e}"
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
# Delete cloud video if present
if meeting and meeting.daily_composed_video_s3_key:
try:
source_storage = get_source_storage("daily")
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
await meetings_controller.update_meeting(
meeting.id,
daily_composed_video_s3_key=None,
daily_composed_video_duration=None,
)
ctx.log(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
except Exception as e:
error_msg = f"Failed to delete cloud video: {e}"
logger.error(error_msg, exc_info=True)
deletion_errors.append(error_msg)
if deletion_errors:
logger.warning(
"[Hatchet] cleanup_consent completed with errors",
transcript_id=input.transcript_id,
error_count=len(deletion_errors),
)
else:
await transcripts_controller.update(transcript, {"audio_deleted": True})
ctx.log("cleanup_consent: all audio and video deleted successfully")
return ConsentResult()
@file_pipeline.task(
parents=[cleanup_consent],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.POST_ZULIP, set_error_status=False)
async def post_zulip(input: FilePipelineInput, ctx: Context) -> ZulipResult:
"""Post notification to Zulip."""
ctx.log(f"post_zulip: transcript_id={input.transcript_id}")
if not settings.ZULIP_REALM:
ctx.log("post_zulip skipped (Zulip not configured)")
return ZulipResult(zulip_message_id=None, skipped=True)
async with fresh_db_connection():
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
from reflector.zulip import post_transcript_notification # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if transcript:
message_id = await post_transcript_notification(transcript)
ctx.log(f"post_zulip complete: zulip_message_id={message_id}")
else:
message_id = None
return ZulipResult(zulip_message_id=message_id)
@file_pipeline.task(
parents=[cleanup_consent],
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.SEND_WEBHOOK, set_error_status=False)
async def send_webhook(input: FilePipelineInput, ctx: Context) -> WebhookResult:
"""Send completion webhook to external service."""
ctx.log(f"send_webhook: transcript_id={input.transcript_id}")
if not input.room_id:
ctx.log("send_webhook skipped (no room_id)")
return WebhookResult(webhook_sent=False, skipped=True)
async with fresh_db_connection():
from reflector.db.rooms import rooms_controller # noqa: PLC0415
from reflector.utils.webhook import ( # noqa: PLC0415
fetch_transcript_webhook_payload,
send_webhook_request,
)
room = await rooms_controller.get_by_id(input.room_id)
if not room or not room.webhook_url:
ctx.log("send_webhook skipped (no webhook_url configured)")
return WebhookResult(webhook_sent=False, skipped=True)
payload = await fetch_transcript_webhook_payload(
transcript_id=input.transcript_id,
room_id=input.room_id,
)
if isinstance(payload, str):
ctx.log(f"send_webhook skipped (could not build payload): {payload}")
return WebhookResult(webhook_sent=False, skipped=True)
import httpx # noqa: PLC0415
try:
response = await send_webhook_request(
url=room.webhook_url,
payload=payload,
event_type="transcript.completed",
webhook_secret=room.webhook_secret,
timeout=30.0,
)
ctx.log(f"send_webhook complete: status_code={response.status_code}")
return WebhookResult(webhook_sent=True, response_code=response.status_code)
except httpx.HTTPStatusError as e:
ctx.log(f"send_webhook failed (HTTP {e.response.status_code}), continuing")
return WebhookResult(
webhook_sent=False, response_code=e.response.status_code
)
except (httpx.ConnectError, httpx.TimeoutException) as e:
ctx.log(f"send_webhook failed ({e}), continuing")
return WebhookResult(webhook_sent=False)
except Exception as e:
ctx.log(f"send_webhook unexpected error: {e}")
return WebhookResult(webhook_sent=False)
@file_pipeline.task(
parents=[cleanup_consent],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.SEND_EMAIL, set_error_status=False)
async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult:
"""Send transcript email to collected recipients."""
ctx.log(f"send_email: transcript_id={input.transcript_id}")
if not is_email_configured():
ctx.log("send_email skipped (SMTP not configured)")
return EmailResult(skipped=True)
async with fresh_db_connection():
from reflector.db.meetings import meetings_controller # noqa: PLC0415
from reflector.db.recordings import recordings_controller # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
ctx.log("send_email skipped (transcript not found)")
return EmailResult(skipped=True)
# Try transcript.meeting_id first, then fall back to recording.meeting_id
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if not meeting and transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
# Normalise meeting recipients (legacy strings → dicts)
meeting_recipients: list[dict] = (
[
entry
if isinstance(entry, dict)
else {"email": entry, "include_link": True}
for entry in (meeting.email_recipients or [])
]
if meeting and meeting.email_recipients
else []
)
# Room-level email always gets a link (room owner)
from reflector.db.rooms import rooms_controller # noqa: PLC0415
room_email = None
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
room_email = room.email_transcript_to
# Build two groups: with link and without link
with_link = [
r["email"] for r in meeting_recipients if r.get("include_link", True)
]
without_link = [
r["email"] for r in meeting_recipients if not r.get("include_link", True)
]
if room_email:
if room_email not in with_link:
with_link.append(room_email)
without_link = [e for e in without_link if e != room_email]
if not with_link and not without_link:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = 0
if with_link:
count += await send_transcript_email(
with_link, transcript, include_link=True
)
if without_link:
count += await send_transcript_email(
without_link, transcript, include_link=False
)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
# --- On failure handler ---
async def on_workflow_failure(input: FilePipelineInput, ctx: Context) -> None:
"""Set transcript status to 'error' only if not already 'ended'."""
async with fresh_db_connection():
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if transcript and transcript.status == "ended":
logger.info(
"[Hatchet] FilePipeline on_workflow_failure: transcript already ended, skipping error status",
transcript_id=input.transcript_id,
)
ctx.log(
"on_workflow_failure: transcript already ended, skipping error status"
)
return
await set_workflow_error_status(input.transcript_id)
@file_pipeline.on_failure_task()
async def _register_on_workflow_failure(input: FilePipelineInput, ctx: Context) -> None:
await on_workflow_failure(input, ctx)

View File

@@ -0,0 +1,481 @@
"""
Hatchet workflow: LivePostProcessingPipeline
Post-processing pipeline for live WebRTC meetings.
Triggered after a live meeting ends. Orchestrates:
Left branch: waveform → convert_mp3 → upload_mp3 → remove_upload → diarize → cleanup_consent
Right branch: generate_title (parallel with left branch)
Fan-in: final_summaries → post_zulip → send_webhook
Note: This file uses deferred imports (inside functions/tasks) intentionally.
Hatchet workers run in forked processes; fresh imports per task ensure DB connections
are not shared across forks, avoiding connection pooling issues.
"""
from datetime import timedelta
from hatchet_sdk import Context
from pydantic import BaseModel
from reflector.email import is_email_configured, send_transcript_email
from reflector.hatchet.client import HatchetClientManager
from reflector.hatchet.constants import (
TIMEOUT_HEAVY,
TIMEOUT_MEDIUM,
TIMEOUT_SHORT,
TIMEOUT_TITLE,
TaskName,
)
from reflector.hatchet.workflows.daily_multitrack_pipeline import (
fresh_db_connection,
set_workflow_error_status,
with_error_handling,
)
from reflector.hatchet.workflows.models import (
ConsentResult,
EmailResult,
TitleResult,
WaveformResult,
WebhookResult,
ZulipResult,
)
from reflector.logger import logger
from reflector.settings import settings
class LivePostPipelineInput(BaseModel):
transcript_id: str
room_id: str | None = None
# --- Result models specific to live post pipeline ---
class ConvertMp3Result(BaseModel):
converted: bool
class UploadMp3Result(BaseModel):
uploaded: bool
class RemoveUploadResult(BaseModel):
removed: bool
class DiarizeResult(BaseModel):
diarized: bool
class FinalSummariesResult(BaseModel):
generated: bool
hatchet = HatchetClientManager.get_client()
live_post_pipeline = hatchet.workflow(
name="LivePostProcessingPipeline", input_validator=LivePostPipelineInput
)
@live_post_pipeline.task(
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.WAVEFORM)
async def waveform(input: LivePostPipelineInput, ctx: Context) -> WaveformResult:
"""Generate waveform visualization from recorded audio."""
ctx.log(f"waveform: starting for transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
from reflector.pipelines.main_live_pipeline import ( # noqa: PLC0415
PipelineMainWaveform,
)
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
raise ValueError(f"Transcript {input.transcript_id} not found")
runner = PipelineMainWaveform(transcript_id=transcript.id)
await runner.run()
ctx.log("waveform complete")
return WaveformResult(waveform_generated=True)
@live_post_pipeline.task(
execution_timeout=timedelta(seconds=TIMEOUT_TITLE),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.GENERATE_TITLE)
async def generate_title(input: LivePostPipelineInput, ctx: Context) -> TitleResult:
"""Generate meeting title from topics (runs in parallel with audio chain)."""
ctx.log(f"generate_title: starting for transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.pipelines.main_live_pipeline import ( # noqa: PLC0415
PipelineMainTitle,
)
runner = PipelineMainTitle(transcript_id=input.transcript_id)
await runner.run()
ctx.log("generate_title complete")
return TitleResult(title=None)
@live_post_pipeline.task(
parents=[waveform],
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.CONVERT_MP3)
async def convert_mp3(input: LivePostPipelineInput, ctx: Context) -> ConvertMp3Result:
"""Convert WAV recording to MP3."""
ctx.log(f"convert_mp3: starting for transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.pipelines.main_live_pipeline import ( # noqa: PLC0415
pipeline_convert_to_mp3,
)
await pipeline_convert_to_mp3(transcript_id=input.transcript_id)
ctx.log("convert_mp3 complete")
return ConvertMp3Result(converted=True)
@live_post_pipeline.task(
parents=[convert_mp3],
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.UPLOAD_MP3)
async def upload_mp3(input: LivePostPipelineInput, ctx: Context) -> UploadMp3Result:
"""Upload MP3 to external storage."""
ctx.log(f"upload_mp3: starting for transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.pipelines.main_live_pipeline import ( # noqa: PLC0415
pipeline_upload_mp3,
)
await pipeline_upload_mp3(transcript_id=input.transcript_id)
ctx.log("upload_mp3 complete")
return UploadMp3Result(uploaded=True)
@live_post_pipeline.task(
parents=[upload_mp3],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=5,
)
@with_error_handling(TaskName.REMOVE_UPLOAD)
async def remove_upload(
input: LivePostPipelineInput, ctx: Context
) -> RemoveUploadResult:
"""Remove the original upload file."""
ctx.log(f"remove_upload: starting for transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.pipelines.main_live_pipeline import ( # noqa: PLC0415
pipeline_remove_upload,
)
await pipeline_remove_upload(transcript_id=input.transcript_id)
ctx.log("remove_upload complete")
return RemoveUploadResult(removed=True)
@live_post_pipeline.task(
parents=[remove_upload],
execution_timeout=timedelta(seconds=TIMEOUT_HEAVY),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=30,
)
@with_error_handling(TaskName.DIARIZE)
async def diarize(input: LivePostPipelineInput, ctx: Context) -> DiarizeResult:
"""Run diarization on the recorded audio."""
ctx.log(f"diarize: starting for transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.pipelines.main_live_pipeline import ( # noqa: PLC0415
pipeline_diarization,
)
await pipeline_diarization(transcript_id=input.transcript_id)
ctx.log("diarize complete")
return DiarizeResult(diarized=True)
@live_post_pipeline.task(
parents=[diarize],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=10,
)
@with_error_handling(TaskName.CLEANUP_CONSENT, set_error_status=False)
async def cleanup_consent(input: LivePostPipelineInput, ctx: Context) -> ConsentResult:
"""Check consent and delete audio files if any participant denied."""
ctx.log(f"cleanup_consent: transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.pipelines.main_live_pipeline import ( # noqa: PLC0415
cleanup_consent as _cleanup_consent,
)
await _cleanup_consent(transcript_id=input.transcript_id)
ctx.log("cleanup_consent complete")
return ConsentResult()
@live_post_pipeline.task(
parents=[cleanup_consent, generate_title],
execution_timeout=timedelta(seconds=TIMEOUT_HEAVY),
retries=3,
backoff_factor=2.0,
backoff_max_seconds=30,
)
@with_error_handling(TaskName.FINAL_SUMMARIES)
async def final_summaries(
input: LivePostPipelineInput, ctx: Context
) -> FinalSummariesResult:
"""Generate final summaries (fan-in after audio chain + title)."""
ctx.log(f"final_summaries: starting for transcript_id={input.transcript_id}")
async with fresh_db_connection():
from reflector.pipelines.main_live_pipeline import ( # noqa: PLC0415
pipeline_summaries,
)
await pipeline_summaries(transcript_id=input.transcript_id)
ctx.log("final_summaries complete")
return FinalSummariesResult(generated=True)
@live_post_pipeline.task(
parents=[final_summaries],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.POST_ZULIP, set_error_status=False)
async def post_zulip(input: LivePostPipelineInput, ctx: Context) -> ZulipResult:
"""Post notification to Zulip."""
ctx.log(f"post_zulip: transcript_id={input.transcript_id}")
if not settings.ZULIP_REALM:
ctx.log("post_zulip skipped (Zulip not configured)")
return ZulipResult(zulip_message_id=None, skipped=True)
async with fresh_db_connection():
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
from reflector.zulip import post_transcript_notification # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if transcript:
message_id = await post_transcript_notification(transcript)
ctx.log(f"post_zulip complete: zulip_message_id={message_id}")
else:
message_id = None
return ZulipResult(zulip_message_id=message_id)
@live_post_pipeline.task(
parents=[final_summaries],
execution_timeout=timedelta(seconds=TIMEOUT_MEDIUM),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.SEND_WEBHOOK, set_error_status=False)
async def send_webhook(input: LivePostPipelineInput, ctx: Context) -> WebhookResult:
"""Send completion webhook to external service."""
ctx.log(f"send_webhook: transcript_id={input.transcript_id}")
if not input.room_id:
ctx.log("send_webhook skipped (no room_id)")
return WebhookResult(webhook_sent=False, skipped=True)
async with fresh_db_connection():
from reflector.db.rooms import rooms_controller # noqa: PLC0415
from reflector.utils.webhook import ( # noqa: PLC0415
fetch_transcript_webhook_payload,
send_webhook_request,
)
room = await rooms_controller.get_by_id(input.room_id)
if not room or not room.webhook_url:
ctx.log("send_webhook skipped (no webhook_url configured)")
return WebhookResult(webhook_sent=False, skipped=True)
payload = await fetch_transcript_webhook_payload(
transcript_id=input.transcript_id,
room_id=input.room_id,
)
if isinstance(payload, str):
ctx.log(f"send_webhook skipped (could not build payload): {payload}")
return WebhookResult(webhook_sent=False, skipped=True)
import httpx # noqa: PLC0415
try:
response = await send_webhook_request(
url=room.webhook_url,
payload=payload,
event_type="transcript.completed",
webhook_secret=room.webhook_secret,
timeout=30.0,
)
ctx.log(f"send_webhook complete: status_code={response.status_code}")
return WebhookResult(webhook_sent=True, response_code=response.status_code)
except httpx.HTTPStatusError as e:
ctx.log(f"send_webhook failed (HTTP {e.response.status_code}), continuing")
return WebhookResult(
webhook_sent=False, response_code=e.response.status_code
)
except (httpx.ConnectError, httpx.TimeoutException) as e:
ctx.log(f"send_webhook failed ({e}), continuing")
return WebhookResult(webhook_sent=False)
except Exception as e:
ctx.log(f"send_webhook unexpected error: {e}")
return WebhookResult(webhook_sent=False)
@live_post_pipeline.task(
parents=[final_summaries],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.SEND_EMAIL, set_error_status=False)
async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult:
"""Send transcript email to collected recipients."""
ctx.log(f"send_email: transcript_id={input.transcript_id}")
if not is_email_configured():
ctx.log("send_email skipped (SMTP not configured)")
return EmailResult(skipped=True)
async with fresh_db_connection():
from reflector.db.meetings import meetings_controller # noqa: PLC0415
from reflector.db.recordings import recordings_controller # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
ctx.log("send_email skipped (transcript not found)")
return EmailResult(skipped=True)
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if not meeting and transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
# Normalise meeting recipients (legacy strings → dicts)
meeting_recipients: list[dict] = (
[
entry
if isinstance(entry, dict)
else {"email": entry, "include_link": True}
for entry in (meeting.email_recipients or [])
]
if meeting and meeting.email_recipients
else []
)
# Room-level email always gets a link (room owner)
from reflector.db.rooms import rooms_controller # noqa: PLC0415
room_email = None
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
room_email = room.email_transcript_to
# Build two groups: with link and without link
with_link = [
r["email"] for r in meeting_recipients if r.get("include_link", True)
]
without_link = [
r["email"] for r in meeting_recipients if not r.get("include_link", True)
]
if room_email:
if room_email not in with_link:
with_link.append(room_email)
without_link = [e for e in without_link if e != room_email]
if not with_link and not without_link:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = 0
if with_link:
count += await send_transcript_email(
with_link, transcript, include_link=True
)
if without_link:
count += await send_transcript_email(
without_link, transcript, include_link=False
)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
# --- On failure handler ---
async def on_workflow_failure(input: LivePostPipelineInput, ctx: Context) -> None:
"""Set transcript status to 'error' only if not already 'ended'."""
async with fresh_db_connection():
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if transcript and transcript.status == "ended":
logger.info(
"[Hatchet] LivePostProcessingPipeline on_workflow_failure: transcript already ended",
transcript_id=input.transcript_id,
)
ctx.log(
"on_workflow_failure: transcript already ended, skipping error status"
)
return
await set_workflow_error_status(input.transcript_id)
@live_post_pipeline.on_failure_task()
async def _register_on_workflow_failure(
input: LivePostPipelineInput, ctx: Context
) -> None:
await on_workflow_failure(input, ctx)

View File

@@ -170,3 +170,10 @@ class WebhookResult(BaseModel):
webhook_sent: bool
skipped: bool = False
response_code: int | None = None
class EmailResult(BaseModel):
"""Result from send_email task."""
emails_sent: int = 0
skipped: bool = False

View File

@@ -17,7 +17,7 @@ from contextlib import asynccontextmanager
from typing import Generic
import av
from celery import chord, current_task, group, shared_task
from celery import current_task, shared_task
from pydantic import BaseModel
from structlog import BoundLogger as Logger
@@ -61,7 +61,7 @@ from reflector.processors.types import (
)
from reflector.processors.types import Transcript as TranscriptProcessorType
from reflector.settings import settings
from reflector.storage import get_transcripts_storage
from reflector.storage import get_source_storage, get_transcripts_storage
from reflector.views.transcripts import GetTranscriptTopic
from reflector.ws_events import TranscriptEventName
from reflector.ws_manager import WebsocketManager, get_ws_manager
@@ -397,7 +397,9 @@ class PipelineMainLive(PipelineMainBase):
# when the pipeline ends, connect to the post pipeline
logger.info("Pipeline main live ended", transcript_id=self.transcript_id)
logger.info("Scheduling pipeline main post", transcript_id=self.transcript_id)
pipeline_post(transcript_id=self.transcript_id)
transcript = await transcripts_controller.get_by_id(self.transcript_id)
room_id = transcript.room_id if transcript else None
await pipeline_post(transcript_id=self.transcript_id, room_id=room_id)
class PipelineMainDiarization(PipelineMainBase[AudioDiarizationInput]):
@@ -669,6 +671,22 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
logger.error(error_msg, exc_info=e)
deletion_errors.append(error_msg)
# Delete cloud video if present
if meeting and meeting.daily_composed_video_s3_key:
try:
source_storage = get_source_storage("daily")
await source_storage.delete_file(meeting.daily_composed_video_s3_key)
await meetings_controller.update_meeting(
meeting.id,
daily_composed_video_s3_key=None,
daily_composed_video_duration=None,
)
logger.info(f"Deleted cloud video: {meeting.daily_composed_video_s3_key}")
except Exception as e:
error_msg = f"Failed to delete cloud video: {e}"
logger.error(error_msg, exc_info=e)
deletion_errors.append(error_msg)
if deletion_errors:
logger.warning(
f"Consent cleanup completed with {len(deletion_errors)} errors",
@@ -676,7 +694,7 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
)
else:
await transcripts_controller.update(transcript, {"audio_deleted": True})
logger.info("Consent cleanup done - all audio deleted")
logger.info("Consent cleanup done - all audio and video deleted")
@get_transcript
@@ -792,29 +810,20 @@ async def task_pipeline_post_to_zulip(*, transcript_id: str):
await pipeline_post_to_zulip(transcript_id=transcript_id)
def pipeline_post(*, transcript_id: str):
async def pipeline_post(*, transcript_id: str, room_id: str | None = None):
"""
Run the post pipeline
Run the post pipeline via Hatchet.
"""
chain_mp3_and_diarize = (
task_pipeline_waveform.si(transcript_id=transcript_id)
| task_pipeline_convert_to_mp3.si(transcript_id=transcript_id)
| task_pipeline_upload_mp3.si(transcript_id=transcript_id)
| task_pipeline_remove_upload.si(transcript_id=transcript_id)
| task_pipeline_diarization.si(transcript_id=transcript_id)
| task_cleanup_consent.si(transcript_id=transcript_id)
)
chain_title_preview = task_pipeline_title.si(transcript_id=transcript_id)
chain_final_summaries = task_pipeline_final_summaries.si(
transcript_id=transcript_id
)
from reflector.hatchet.client import HatchetClientManager # noqa: PLC0415
chain = chord(
group(chain_mp3_and_diarize, chain_title_preview),
chain_final_summaries,
) | task_pipeline_post_to_zulip.si(transcript_id=transcript_id)
return chain.delay()
await HatchetClientManager.start_workflow(
"LivePostProcessingPipeline",
{
"transcript_id": str(transcript_id),
"room_id": str(room_id) if room_id else None,
},
additional_metadata={"transcript_id": str(transcript_id)},
)
@get_transcript

View File

@@ -4,6 +4,8 @@ from .audio_diarization_auto import AudioDiarizationAutoProcessor # noqa: F401
from .audio_downscale import AudioDownscaleProcessor # noqa: F401
from .audio_file_writer import AudioFileWriterProcessor # noqa: F401
from .audio_merge import AudioMergeProcessor # noqa: F401
from .audio_mixdown import AudioMixdownProcessor # noqa: F401
from .audio_mixdown_auto import AudioMixdownAutoProcessor # noqa: F401
from .audio_padding import AudioPaddingProcessor # noqa: F401
from .audio_padding_auto import AudioPaddingAutoProcessor # noqa: F401
from .audio_transcript import AudioTranscriptProcessor # noqa: F401

View File

@@ -0,0 +1,27 @@
"""
Base class for audio mixdown processors.
"""
from pydantic import BaseModel
class MixdownResponse(BaseModel):
size: int
duration_ms: float = 0.0
cancelled: bool = False
output_path: str | None = (
None # Local file path (pyav sets this; modal leaves None)
)
class AudioMixdownProcessor:
"""Base class for audio mixdown processors."""
async def mixdown_tracks(
self,
track_urls: list[str],
output_url: str,
target_sample_rate: int | None = None,
offsets_seconds: list[float] | None = None,
) -> MixdownResponse:
raise NotImplementedError

View File

@@ -0,0 +1,32 @@
import importlib
from reflector.processors.audio_mixdown import AudioMixdownProcessor
from reflector.settings import settings
class AudioMixdownAutoProcessor(AudioMixdownProcessor):
_registry = {}
@classmethod
def register(cls, name, kclass):
cls._registry[name] = kclass
def __new__(cls, name: str | None = None, **kwargs):
if name is None:
name = settings.MIXDOWN_BACKEND
if name not in cls._registry:
module_name = f"reflector.processors.audio_mixdown_{name}"
importlib.import_module(module_name)
# gather specific configuration for the processor
# search `MIXDOWN_XXX_YYY`, push to constructor as `xxx_yyy`
config = {}
name_upper = name.upper()
settings_prefix = "MIXDOWN_"
config_prefix = f"{settings_prefix}{name_upper}_"
for key, value in settings:
if key.startswith(config_prefix):
config_name = key[len(settings_prefix) :].lower()
config[config_name] = value
return cls._registry[name](**config | kwargs)

View File

@@ -0,0 +1,110 @@
"""
Modal.com backend for audio mixdown.
"""
import asyncio
import os
import httpx
from reflector.hatchet.constants import TIMEOUT_HEAVY_HTTP
from reflector.logger import logger
from reflector.processors.audio_mixdown import AudioMixdownProcessor, MixdownResponse
from reflector.processors.audio_mixdown_auto import AudioMixdownAutoProcessor
class AudioMixdownModalProcessor(AudioMixdownProcessor):
"""Audio mixdown processor using Modal.com/self-hosted backend via HTTP."""
def __init__(
self, mixdown_url: str | None = None, modal_api_key: str | None = None
):
self.mixdown_url = mixdown_url or os.getenv("MIXDOWN_URL")
if not self.mixdown_url:
raise ValueError(
"MIXDOWN_URL required to use AudioMixdownModalProcessor. "
"Set MIXDOWN_URL environment variable or pass mixdown_url parameter."
)
self.modal_api_key = modal_api_key or os.getenv("MODAL_API_KEY")
async def mixdown_tracks(
self,
track_urls: list[str],
output_url: str,
target_sample_rate: int | None = None,
offsets_seconds: list[float] | None = None,
) -> MixdownResponse:
"""Mix audio tracks via remote Modal/self-hosted backend.
Args:
track_urls: Presigned GET URLs for source audio tracks
output_url: Presigned PUT URL for output MP3
target_sample_rate: Sample rate for output (Hz), auto-detected if None
offsets_seconds: Optional per-track delays in seconds for alignment
"""
valid_count = len([u for u in track_urls if u])
log = logger.bind(track_count=valid_count)
log.info("Sending Modal mixdown HTTP request")
url = f"{self.mixdown_url}/mixdown"
headers = {}
if self.modal_api_key:
headers["Authorization"] = f"Bearer {self.modal_api_key}"
# Scale timeout with track count: base TIMEOUT_HEAVY_HTTP + 60s per track beyond 2
extra_timeout = max(0, (valid_count - 2)) * 60
timeout = TIMEOUT_HEAVY_HTTP + extra_timeout
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(
url,
headers=headers,
json={
"track_urls": track_urls,
"output_url": output_url,
"target_sample_rate": target_sample_rate,
"offsets_seconds": offsets_seconds,
},
follow_redirects=True,
)
if response.status_code != 200:
error_body = response.text
log.error(
"Modal mixdown API error",
status_code=response.status_code,
error_body=error_body,
)
response.raise_for_status()
result = response.json()
# Check if work was cancelled
if result.get("cancelled"):
log.warning("Modal mixdown was cancelled by disconnect detection")
raise asyncio.CancelledError(
"Mixdown cancelled due to client disconnect"
)
log.info("Modal mixdown complete", size=result["size"])
return MixdownResponse(**result)
except asyncio.CancelledError:
log.warning(
"Modal mixdown cancelled (Hatchet timeout, disconnect detected on Modal side)"
)
raise
except httpx.TimeoutException as e:
log.error("Modal mixdown timeout", error=str(e), exc_info=True)
raise Exception(f"Modal mixdown timeout: {e}") from e
except httpx.HTTPStatusError as e:
log.error("Modal mixdown HTTP error", error=str(e), exc_info=True)
raise Exception(f"Modal mixdown HTTP error: {e}") from e
except Exception as e:
log.error("Modal mixdown unexpected error", error=str(e), exc_info=True)
raise
AudioMixdownAutoProcessor.register("modal", AudioMixdownModalProcessor)

View File

@@ -0,0 +1,101 @@
"""
PyAV audio mixdown processor.
Mixes N tracks in-process using the existing utility from reflector.utils.audio_mixdown.
Writes to a local temp file (does NOT upload to S3 — the pipeline handles upload).
"""
import os
import tempfile
from reflector.logger import logger
from reflector.processors.audio_file_writer import AudioFileWriterProcessor
from reflector.processors.audio_mixdown import AudioMixdownProcessor, MixdownResponse
from reflector.processors.audio_mixdown_auto import AudioMixdownAutoProcessor
from reflector.utils.audio_mixdown import (
detect_sample_rate_from_tracks,
mixdown_tracks_pyav,
)
class AudioMixdownPyavProcessor(AudioMixdownProcessor):
"""Audio mixdown processor using PyAV (no HTTP backend).
Writes the mixed output to a local temp file and returns its path
in MixdownResponse.output_path. The caller is responsible for
uploading the file and cleaning it up.
"""
async def mixdown_tracks(
self,
track_urls: list[str],
output_url: str,
target_sample_rate: int | None = None,
offsets_seconds: list[float] | None = None,
) -> MixdownResponse:
log = logger.bind(track_count=len(track_urls))
log.info("Starting local PyAV mixdown")
valid_urls = [url for url in track_urls if url]
if not valid_urls:
raise ValueError("No valid track URLs provided")
# Auto-detect sample rate if not provided
if target_sample_rate is None:
target_sample_rate = detect_sample_rate_from_tracks(
valid_urls, logger=logger
)
if not target_sample_rate:
raise ValueError("No decodable audio frames in any track")
# Write to temp MP3 file
temp_dir = tempfile.mkdtemp()
output_path = os.path.join(temp_dir, "mixed.mp3")
duration_ms_container = [0.0]
async def capture_duration(d):
duration_ms_container[0] = d
writer = AudioFileWriterProcessor(
path=output_path, on_duration=capture_duration
)
try:
await mixdown_tracks_pyav(
valid_urls,
writer,
target_sample_rate,
offsets_seconds=offsets_seconds,
logger=logger,
)
await writer.flush()
file_size = os.path.getsize(output_path)
log.info(
"Local mixdown complete",
size=file_size,
duration_ms=duration_ms_container[0],
)
return MixdownResponse(
size=file_size,
duration_ms=duration_ms_container[0],
output_path=output_path,
)
except Exception as e:
# Cleanup on failure
if os.path.exists(output_path):
try:
os.unlink(output_path)
except Exception:
pass
try:
os.rmdir(temp_dir)
except Exception:
pass
log.error("Local mixdown failed", error=str(e), exc_info=True)
raise
AudioMixdownAutoProcessor.register("pyav", AudioMixdownPyavProcessor)

View File

@@ -10,7 +10,6 @@ from dataclasses import dataclass
from typing import Literal, Union, assert_never
import celery
from celery.result import AsyncResult
from hatchet_sdk.clients.rest.exceptions import ApiException, NotFoundException
from hatchet_sdk.clients.rest.models import V1TaskStatus
@@ -18,7 +17,6 @@ from reflector.db.recordings import recordings_controller
from reflector.db.transcripts import Transcript, transcripts_controller
from reflector.hatchet.client import HatchetClientManager
from reflector.logger import logger
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
from reflector.utils.string import NonEmptyString
@@ -105,11 +103,8 @@ async def validate_transcript_for_processing(
):
return ValidationNotReady(detail="Recording is not ready for processing")
# Check Celery tasks
# Check Celery tasks (multitrack still uses Celery for some paths)
if task_is_scheduled_or_active(
"reflector.pipelines.main_file_pipeline.task_pipeline_file_process",
transcript_id=transcript.id,
) or task_is_scheduled_or_active(
"reflector.pipelines.main_multitrack_pipeline.task_pipeline_multitrack_process",
transcript_id=transcript.id,
):
@@ -175,11 +170,8 @@ async def prepare_transcript_processing(validation: ValidationOk) -> PrepareResu
async def dispatch_transcript_processing(
config: ProcessingConfig, force: bool = False
) -> AsyncResult | None:
"""Dispatch transcript processing to appropriate backend (Hatchet or Celery).
Returns AsyncResult for Celery tasks, None for Hatchet workflows.
"""
) -> None:
"""Dispatch transcript processing to Hatchet workflow engine."""
if isinstance(config, MultitrackProcessingConfig):
# Multitrack processing always uses Hatchet (no Celery fallback)
# First check if we can replay (outside transaction since it's read-only)
@@ -275,7 +267,21 @@ async def dispatch_transcript_processing(
return None
elif isinstance(config, FileProcessingConfig):
return task_pipeline_file_process.delay(transcript_id=config.transcript_id)
# File processing uses Hatchet workflow
workflow_id = await HatchetClientManager.start_workflow(
workflow_name="FilePipeline",
input_data={"transcript_id": config.transcript_id},
additional_metadata={"transcript_id": config.transcript_id},
)
transcript = await transcripts_controller.get_by_id(config.transcript_id)
if transcript:
await transcripts_controller.update(
transcript, {"workflow_run_id": workflow_id}
)
logger.info("File pipeline dispatched via Hatchet", workflow_id=workflow_id)
return None
else:
assert_never(config)

View File

@@ -127,6 +127,14 @@ class Settings(BaseSettings):
PADDING_URL: str | None = None
PADDING_MODAL_API_KEY: str | None = None
# Audio Mixdown
# backends:
# - pyav: in-process PyAV mixdown (no HTTP, runs in same process)
# - modal: HTTP API client (works with Modal.com OR self-hosted gpu/self_hosted/)
MIXDOWN_BACKEND: str = "pyav"
MIXDOWN_URL: str | None = None
MIXDOWN_MODAL_API_KEY: str | None = None
# Sentry
SENTRY_DSN: str | None = None
@@ -180,6 +188,7 @@ class Settings(BaseSettings):
)
# Daily.co integration
DAILY_API_URL: str = "https://api.daily.co/v1"
DAILY_API_KEY: str | None = None
DAILY_WEBHOOK_SECRET: str | None = None
DAILY_SUBDOMAIN: str | None = None
@@ -193,6 +202,16 @@ class Settings(BaseSettings):
ZULIP_REALM: str | None = None
ZULIP_API_KEY: str | None = None
ZULIP_BOT_EMAIL: str | None = None
ZULIP_DAG_STREAM: str | None = None
ZULIP_DAG_TOPIC: str | None = None
# Email / SMTP integration (for transcript email notifications)
SMTP_HOST: str | None = None
SMTP_PORT: int = 587
SMTP_USERNAME: str | None = None
SMTP_PASSWORD: str | None = None
SMTP_FROM_EMAIL: str | None = None
SMTP_USE_TLS: bool = True
# Hatchet workflow orchestration (always enabled for multitrack processing)
HATCHET_CLIENT_TOKEN: str | None = None

View File

@@ -116,9 +116,12 @@ class Storage:
expires_in: int = 3600,
*,
bucket: str | None = None,
extra_params: dict | None = None,
) -> str:
"""Generate presigned URL. bucket: override instance default if provided."""
return await self._get_file_url(filename, operation, expires_in, bucket=bucket)
return await self._get_file_url(
filename, operation, expires_in, bucket=bucket, extra_params=extra_params
)
async def _get_file_url(
self,
@@ -127,6 +130,7 @@ class Storage:
expires_in: int = 3600,
*,
bucket: str | None = None,
extra_params: dict | None = None,
) -> str:
raise NotImplementedError

View File

@@ -170,16 +170,23 @@ class AwsStorage(Storage):
expires_in: int = 3600,
*,
bucket: str | None = None,
extra_params: dict | None = None,
) -> str:
actual_bucket = bucket or self._bucket_name
folder = self.aws_folder
s3filename = f"{folder}/{filename}" if folder else filename
params = {}
if extra_params:
params.update(extra_params)
# Always set Bucket/Key after extra_params to prevent overrides
params["Bucket"] = actual_bucket
params["Key"] = s3filename
async with self.session.client(
"s3", config=self.boto_config, endpoint_url=self._endpoint_url
) as client:
presigned_url = await client.generate_presigned_url(
operation,
Params={"Bucket": actual_bucket, "Key": s3filename},
Params=params,
ExpiresIn=expires_in,
)

View File

@@ -0,0 +1,257 @@
#!/usr/bin/env python
"""
CLI tool for managing soft-deleted transcripts.
Usage:
uv run python -m reflector.tools.deleted_transcripts list
uv run python -m reflector.tools.deleted_transcripts files <transcript_id>
uv run python -m reflector.tools.deleted_transcripts download <transcript_id> [--output-dir ./]
"""
import argparse
import asyncio
import json
import os
import structlog
from reflector.db import get_database
from reflector.db.meetings import meetings_controller
from reflector.db.recordings import recordings_controller
from reflector.db.transcripts import Transcript, transcripts
from reflector.storage import get_source_storage, get_transcripts_storage
logger = structlog.get_logger(__name__)
async def list_deleted():
"""List all soft-deleted transcripts."""
database = get_database()
await database.connect()
try:
query = (
transcripts.select()
.where(transcripts.c.deleted_at.isnot(None))
.order_by(transcripts.c.deleted_at.desc())
)
results = await database.fetch_all(query)
if not results:
print("No deleted transcripts found.")
return
print(
f"{'ID':<40} {'Title':<40} {'Deleted At':<28} {'Recording ID':<40} {'Meeting ID'}"
)
print("-" * 180)
for row in results:
t = Transcript(**row)
title = (t.title or "")[:38]
deleted = t.deleted_at.isoformat() if t.deleted_at else ""
print(
f"{t.id:<40} {title:<40} {deleted:<28} {t.recording_id or '':<40} {t.meeting_id or ''}"
)
print(f"\nTotal: {len(results)} deleted transcript(s)")
finally:
await database.disconnect()
async def list_files(transcript_id: str):
"""List all S3 keys associated with a deleted transcript."""
database = get_database()
await database.connect()
try:
query = transcripts.select().where(transcripts.c.id == transcript_id)
result = await database.fetch_one(query)
if not result:
print(f"Transcript {transcript_id} not found.")
return
t = Transcript(**result)
if t.deleted_at is None:
print(f"Transcript {transcript_id} is not deleted.")
return
print(f"Transcript: {t.id}")
print(f"Title: {t.title}")
print(f"Deleted at: {t.deleted_at}")
print()
files = []
# Transcript audio
if t.audio_location == "storage" and not t.audio_deleted:
files.append(("Transcript audio", t.storage_audio_path, None))
# Recording files
if t.recording_id:
recording = await recordings_controller.get_by_id(t.recording_id)
if recording:
if recording.object_key:
files.append(
(
"Recording object_key",
recording.object_key,
recording.bucket_name,
)
)
if recording.track_keys:
for i, key in enumerate(recording.track_keys):
files.append((f"Track {i}", key, recording.bucket_name))
# Cloud video
if t.meeting_id:
meeting = await meetings_controller.get_by_id(t.meeting_id)
if meeting and meeting.daily_composed_video_s3_key:
files.append(("Cloud video", meeting.daily_composed_video_s3_key, None))
if not files:
print("No associated files found.")
return
print(f"{'Type':<25} {'Bucket':<30} {'S3 Key'}")
print("-" * 120)
for label, key, bucket in files:
print(f"{label:<25} {bucket or '(default)':<30} {key}")
# Generate presigned URLs
print("\nPresigned URLs (valid for 1 hour):")
print("-" * 120)
storage = get_transcripts_storage()
for label, key, bucket in files:
try:
url = await storage.get_file_url(key, bucket=bucket, expires_in=3600)
print(f"{label}: {url}")
except Exception as e:
print(f"{label}: ERROR - {e}")
finally:
await database.disconnect()
async def download_files(transcript_id: str, output_dir: str):
"""Download all files associated with a deleted transcript."""
database = get_database()
await database.connect()
try:
query = transcripts.select().where(transcripts.c.id == transcript_id)
result = await database.fetch_one(query)
if not result:
print(f"Transcript {transcript_id} not found.")
return
t = Transcript(**result)
if t.deleted_at is None:
print(f"Transcript {transcript_id} is not deleted.")
return
dest = os.path.join(output_dir, t.id)
os.makedirs(dest, exist_ok=True)
storage = get_transcripts_storage()
# Download transcript audio
if t.audio_location == "storage" and not t.audio_deleted:
try:
data = await storage.get_file(t.storage_audio_path)
path = os.path.join(dest, "audio.mp3")
with open(path, "wb") as f:
f.write(data)
print(f"Downloaded: {path}")
except Exception as e:
print(f"Failed to download audio: {e}")
# Download recording files
if t.recording_id:
recording = await recordings_controller.get_by_id(t.recording_id)
if recording and recording.track_keys:
tracks_dir = os.path.join(dest, "tracks")
os.makedirs(tracks_dir, exist_ok=True)
for i, key in enumerate(recording.track_keys):
try:
data = await storage.get_file(key, bucket=recording.bucket_name)
filename = os.path.basename(key) or f"track_{i}"
path = os.path.join(tracks_dir, filename)
with open(path, "wb") as f:
f.write(data)
print(f"Downloaded: {path}")
except Exception as e:
print(f"Failed to download track {i}: {e}")
# Download cloud video
if t.meeting_id:
meeting = await meetings_controller.get_by_id(t.meeting_id)
if meeting and meeting.daily_composed_video_s3_key:
try:
source_storage = get_source_storage("daily")
data = await source_storage.get_file(
meeting.daily_composed_video_s3_key
)
path = os.path.join(dest, "cloud_video.mp4")
with open(path, "wb") as f:
f.write(data)
print(f"Downloaded: {path}")
except Exception as e:
print(f"Failed to download cloud video: {e}")
# Write metadata
metadata = {
"id": t.id,
"title": t.title,
"created_at": t.created_at.isoformat() if t.created_at else None,
"deleted_at": t.deleted_at.isoformat() if t.deleted_at else None,
"duration": t.duration,
"source_language": t.source_language,
"target_language": t.target_language,
"short_summary": t.short_summary,
"long_summary": t.long_summary,
"topics": [topic.model_dump() for topic in t.topics] if t.topics else [],
"participants": [p.model_dump() for p in t.participants]
if t.participants
else [],
"action_items": t.action_items,
"webvtt": t.webvtt,
"recording_id": t.recording_id,
"meeting_id": t.meeting_id,
}
path = os.path.join(dest, "metadata.json")
with open(path, "w") as f:
json.dump(metadata, f, indent=2, default=str)
print(f"Downloaded: {path}")
print(f"\nAll files saved to: {dest}")
finally:
await database.disconnect()
def main():
parser = argparse.ArgumentParser(description="Manage soft-deleted transcripts")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("list", help="List all deleted transcripts")
files_parser = subparsers.add_parser(
"files", help="List S3 keys for a deleted transcript"
)
files_parser.add_argument("transcript_id", help="Transcript ID")
download_parser = subparsers.add_parser(
"download", help="Download files for a deleted transcript"
)
download_parser.add_argument("transcript_id", help="Transcript ID")
download_parser.add_argument(
"--output-dir", default=".", help="Output directory (default: .)"
)
args = parser.parse_args()
if args.command == "list":
asyncio.run(list_deleted())
elif args.command == "files":
asyncio.run(list_files(args.transcript_id))
elif args.command == "download":
asyncio.run(download_files(args.transcript_id, args.output_dir))
if __name__ == "__main__":
main()

View File

@@ -7,7 +7,6 @@ import asyncio
import json
import shutil
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Literal, Tuple
from urllib.parse import unquote, urlparse
@@ -15,10 +14,8 @@ from urllib.parse import unquote, urlparse
from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
from reflector.db.transcripts import SourceKind, TranscriptTopic, transcripts_controller
from reflector.hatchet.client import HatchetClientManager
from reflector.logger import logger
from reflector.pipelines.main_file_pipeline import (
task_pipeline_file_process as task_pipeline_file_process,
)
from reflector.pipelines.main_live_pipeline import pipeline_post as live_pipeline_post
from reflector.pipelines.main_live_pipeline import (
pipeline_process as live_pipeline_process,
@@ -237,29 +234,22 @@ async def process_live_pipeline(
# assert documented behaviour: after process, the pipeline isn't ended. this is the reason of calling pipeline_post
assert pre_final_transcript.status != "ended"
# at this point, diarization is running but we have no access to it. run diarization in parallel - one will hopefully win after polling
result = live_pipeline_post(transcript_id=transcript_id)
# result.ready() blocks even without await; it mutates result also
while not result.ready():
print(f"Status: {result.state}")
time.sleep(2)
# Trigger post-processing via Hatchet (fire-and-forget)
await live_pipeline_post(transcript_id=transcript_id)
print("Live post-processing pipeline triggered via Hatchet", file=sys.stderr)
async def process_file_pipeline(
transcript_id: TranscriptId,
):
"""Process audio/video file using the optimized file pipeline"""
"""Process audio/video file using the optimized file pipeline via Hatchet"""
# task_pipeline_file_process is a Celery task, need to use .delay() for async execution
result = task_pipeline_file_process.delay(transcript_id=transcript_id)
# Wait for the Celery task to complete
while not result.ready():
print(f"File pipeline status: {result.state}", file=sys.stderr)
time.sleep(2)
logger.info("File pipeline processing complete")
await HatchetClientManager.start_workflow(
"FilePipeline",
{"transcript_id": str(transcript_id)},
additional_metadata={"transcript_id": str(transcript_id)},
)
print("File pipeline triggered via Hatchet", file=sys.stderr)
async def process(
@@ -293,7 +283,16 @@ async def process(
await handler(transcript_id)
await extract_result_from_entry(transcript_id, output_path)
if pipeline == "file":
# File pipeline is async via Hatchet — results not available immediately.
# Use reflector.tools.process_transcript with --sync for polling.
print(
f"File pipeline dispatched for transcript {transcript_id}. "
f"Results will be available once the Hatchet workflow completes.",
file=sys.stderr,
)
else:
await extract_result_from_entry(transcript_id, output_path)
finally:
await database.disconnect()

View File

@@ -11,10 +11,8 @@ Usage:
import argparse
import asyncio
import sys
import time
from typing import Callable
from celery.result import AsyncResult
from hatchet_sdk.clients.rest.models import V1TaskStatus
import reflector._warnings_filter # noqa: F401 -- side effect: suppress pydantic validate_default warning
@@ -39,7 +37,7 @@ async def process_transcript_inner(
on_validation: Callable[[ValidationResult], None],
on_preprocess: Callable[[PrepareResult], None],
force: bool = False,
) -> AsyncResult | None:
) -> None:
validation = await validate_transcript_for_processing(transcript)
on_validation(validation)
config = await prepare_transcript_processing(validation)
@@ -87,56 +85,39 @@ async def process_transcript(
elif isinstance(config, FileProcessingConfig):
print(f"Dispatching file pipeline", file=sys.stderr)
result = await process_transcript_inner(
await process_transcript_inner(
transcript,
on_validation=on_validation,
on_preprocess=on_preprocess,
force=force,
)
if result is None:
# Hatchet workflow dispatched
if sync:
# Re-fetch transcript to get workflow_run_id
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript or not transcript.workflow_run_id:
print("Error: workflow_run_id not found", file=sys.stderr)
if sync:
# Re-fetch transcript to get workflow_run_id
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript or not transcript.workflow_run_id:
print("Error: workflow_run_id not found", file=sys.stderr)
sys.exit(1)
print("Waiting for Hatchet workflow...", file=sys.stderr)
while True:
status = await HatchetClientManager.get_workflow_run_status(
transcript.workflow_run_id
)
print(f" Status: {status.value}", file=sys.stderr)
if status == V1TaskStatus.COMPLETED:
print("Workflow completed successfully", file=sys.stderr)
break
elif status in (V1TaskStatus.FAILED, V1TaskStatus.CANCELLED):
print(f"Workflow failed: {status}", file=sys.stderr)
sys.exit(1)
print("Waiting for Hatchet workflow...", file=sys.stderr)
while True:
status = await HatchetClientManager.get_workflow_run_status(
transcript.workflow_run_id
)
print(f" Status: {status.value}", file=sys.stderr)
if status == V1TaskStatus.COMPLETED:
print("Workflow completed successfully", file=sys.stderr)
break
elif status in (V1TaskStatus.FAILED, V1TaskStatus.CANCELLED):
print(f"Workflow failed: {status}", file=sys.stderr)
sys.exit(1)
await asyncio.sleep(5)
else:
print(
"Task dispatched (use --sync to wait for completion)",
file=sys.stderr,
)
elif sync:
print("Waiting for task completion...", file=sys.stderr)
while not result.ready():
print(f" Status: {result.state}", file=sys.stderr)
time.sleep(5)
if result.successful():
print("Task completed successfully", file=sys.stderr)
else:
print(f"Task failed: {result.result}", file=sys.stderr)
sys.exit(1)
await asyncio.sleep(5)
else:
print(
"Task dispatched (use --sync to wait for completion)", file=sys.stderr
"Task dispatched (use --sync to wait for completion)",
file=sys.stderr,
)
finally:

View File

@@ -0,0 +1,412 @@
"""
Render Hatchet workflow runs as text DAG.
Usage:
# Show latest 5 runs (summary table)
uv run -m reflector.tools.render_hatchet_run
# Show specific run with full DAG + task details
uv run -m reflector.tools.render_hatchet_run <workflow_run_id>
# Drill into Nth run from the list (1-indexed)
uv run -m reflector.tools.render_hatchet_run --show 1
# Show latest N runs
uv run -m reflector.tools.render_hatchet_run --last 10
# Filter by status
uv run -m reflector.tools.render_hatchet_run --status FAILED
uv run -m reflector.tools.render_hatchet_run --status RUNNING
"""
import argparse
import asyncio
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from hatchet_sdk.clients.rest.models import (
V1TaskEvent,
V1TaskStatus,
V1TaskSummary,
V1WorkflowRunDetails,
WorkflowRunShapeItemForWorkflowRunDetails,
)
from reflector.hatchet.client import HatchetClientManager
STATUS_ICON = {
V1TaskStatus.COMPLETED: "\u2705",
V1TaskStatus.RUNNING: "\u23f3",
V1TaskStatus.FAILED: "\u274c",
V1TaskStatus.QUEUED: "\u23f8\ufe0f",
V1TaskStatus.CANCELLED: "\u26a0\ufe0f",
}
STATUS_LABEL = {
V1TaskStatus.COMPLETED: "Complete",
V1TaskStatus.RUNNING: "Running",
V1TaskStatus.FAILED: "FAILED",
V1TaskStatus.QUEUED: "Queued",
V1TaskStatus.CANCELLED: "Cancelled",
}
def _fmt_time(dt: datetime | None) -> str:
if dt is None:
return "-"
return dt.strftime("%H:%M:%S")
def _fmt_duration(ms: int | None) -> str:
if ms is None:
return "-"
secs = ms / 1000
if secs < 60:
return f"{secs:.1f}s"
mins = secs / 60
return f"{mins:.1f}m"
def _fmt_status_line(task: V1TaskSummary) -> str:
"""Format a status line like: Complete (finished 20:31:44)"""
label = STATUS_LABEL.get(task.status, task.status.value)
icon = STATUS_ICON.get(task.status, "?")
if task.status == V1TaskStatus.COMPLETED and task.finished_at:
return f"{icon} {label} (finished {_fmt_time(task.finished_at)})"
elif task.status == V1TaskStatus.RUNNING and task.started_at:
parts = [f"started {_fmt_time(task.started_at)}"]
if task.duration:
parts.append(f"{_fmt_duration(task.duration)} elapsed")
return f"{icon} {label} ({', '.join(parts)})"
elif task.status == V1TaskStatus.FAILED and task.finished_at:
return f"{icon} {label} (failed {_fmt_time(task.finished_at)})"
elif task.status == V1TaskStatus.CANCELLED:
return f"{icon} {label}"
elif task.status == V1TaskStatus.QUEUED:
return f"{icon} {label}"
return f"{icon} {label}"
def _topo_sort(
shape: list[WorkflowRunShapeItemForWorkflowRunDetails],
) -> list[str]:
"""Topological sort of step_ids from shape DAG."""
step_ids = {s.step_id for s in shape}
children_map: dict[str, list[str]] = {}
in_degree: dict[str, int] = {sid: 0 for sid in step_ids}
for s in shape:
children = [c for c in (s.children_step_ids or []) if c in step_ids]
children_map[s.step_id] = children
for c in children:
in_degree[c] += 1
queue = sorted(sid for sid, deg in in_degree.items() if deg == 0)
result: list[str] = []
while queue:
node = queue.pop(0)
result.append(node)
for c in children_map.get(node, []):
in_degree[c] -= 1
if in_degree[c] == 0:
queue.append(c)
queue.sort()
return result
def render_run_detail(details: V1WorkflowRunDetails) -> str:
"""Render a single workflow run as markdown DAG with task details."""
shape = details.shape or []
tasks = details.tasks or []
events = details.task_events or []
run = details.run
if not shape:
return f"Run {run.metadata.id}: {run.status.value} (no shape data)"
# Build lookups
step_to_shape: dict[str, WorkflowRunShapeItemForWorkflowRunDetails] = {
s.step_id: s for s in shape
}
step_to_name: dict[str, str] = {s.step_id: s.task_name for s in shape}
# Reverse edges (parents)
parents: dict[str, list[str]] = {s.step_id: [] for s in shape}
for s in shape:
for child_id in s.children_step_ids or []:
if child_id in parents:
parents[child_id].append(s.step_id)
# Join tasks by step_id
task_by_step: dict[str, V1TaskSummary] = {}
for t in tasks:
if t.step_id and t.step_id in step_to_name:
task_by_step[t.step_id] = t
# Events indexed by task_external_id
events_by_task: dict[str, list[V1TaskEvent]] = defaultdict(list)
for ev in events:
events_by_task[ev.task_id].append(ev)
ordered = _topo_sort(shape)
lines: list[str] = []
# Run header
run_icon = STATUS_ICON.get(run.status, "?")
run_name = run.display_name or run.workflow_id
dur = _fmt_duration(run.duration)
lines.append(f"**{run_name}** {run_icon} {dur}")
lines.append(f"ID: `{run.metadata.id}`")
if run.additional_metadata:
meta_parts = [f"{k}=`{v}`" for k, v in run.additional_metadata.items()]
lines.append(f"Meta: {', '.join(meta_parts)}")
if run.error_message:
# Take first line of error only for header
first_line = run.error_message.split("\n")[0]
lines.append(f"Error: {first_line}")
lines.append("")
# DAG Status Overview table (collapsible)
lines.append("```spoiler DAG Status Overview")
lines.append("| Node | Status | Duration | Dependencies |")
lines.append("|------|--------|----------|--------------|")
for step_id in ordered:
s = step_to_shape[step_id]
t = task_by_step.get(step_id)
name = step_to_name[step_id]
icon = STATUS_ICON.get(t.status, "?") if t else "?"
dur = _fmt_duration(t.duration) if t else "-"
parent_names = [step_to_name[p] for p in parents[step_id]]
child_names = [
step_to_name[c] for c in (s.children_step_ids or []) if c in step_to_name
]
deps_left = ", ".join(parent_names) if parent_names else ""
deps_right = ", ".join(child_names) if child_names else ""
if deps_left and deps_right:
deps = f"{deps_left} \u2192 {deps_right}"
elif deps_right:
deps = f"\u2192 {deps_right}"
elif deps_left:
deps = f"{deps_left} \u2192"
else:
deps = "-"
lines.append(f"| {name} | {icon} | {dur} | {deps} |")
lines.append("```")
lines.append("")
# Node details (collapsible)
lines.append("```spoiler Node Details")
for step_id in ordered:
t = task_by_step.get(step_id)
name = step_to_name[step_id]
if not t:
lines.append(f"**\U0001f4e6 {name}**")
lines.append("Status: no task data")
lines.append("")
continue
lines.append(f"**\U0001f4e6 {name}**")
lines.append(f"Status: {_fmt_status_line(t)}")
if t.duration:
lines.append(f"Duration: {_fmt_duration(t.duration)}")
if t.retry_count and t.retry_count > 0:
lines.append(f"Retries: {t.retry_count}")
# Fan-out children
if t.num_spawned_children and t.num_spawned_children > 0:
children = t.children or []
completed = sum(1 for c in children if c.status == V1TaskStatus.COMPLETED)
failed = sum(1 for c in children if c.status == V1TaskStatus.FAILED)
running = sum(1 for c in children if c.status == V1TaskStatus.RUNNING)
lines.append(
f"Spawned children: {completed}/{t.num_spawned_children} done"
f"{f', {running} running' if running else ''}"
f"{f', {failed} failed' if failed else ''}"
)
# Error message (first meaningful line only, full trace in events)
if t.error_message:
err_lines = t.error_message.strip().split("\n")
# Find first non-empty, non-traceback line
err_summary = err_lines[0]
for line in err_lines:
stripped = line.strip()
if stripped and not stripped.startswith(
("Traceback", "File ", "{", ")")
):
err_summary = stripped
break
lines.append(f"Error: `{err_summary}`")
# Events log
task_events = sorted(
events_by_task.get(t.task_external_id, []),
key=lambda e: e.timestamp,
)
if task_events:
lines.append("Events:")
for ev in task_events:
ts = ev.timestamp.strftime("%H:%M:%S")
ev_icon = ""
if ev.event_type.value == "FINISHED":
ev_icon = "\u2705 "
elif ev.event_type.value in ("FAILED", "TIMED_OUT"):
ev_icon = "\u274c "
elif ev.event_type.value == "STARTED":
ev_icon = "\u25b6\ufe0f "
elif ev.event_type.value == "RETRYING":
ev_icon = "\U0001f504 "
elif ev.event_type.value == "CANCELLED":
ev_icon = "\u26a0\ufe0f "
msg = ev.message.strip()
if ev.error_message:
# Just first line of error in event log
err_first = ev.error_message.strip().split("\n")[0]
if msg:
msg += f" | {err_first}"
else:
msg = err_first
if msg:
lines.append(f" `{ts}` {ev_icon}{ev.event_type.value}: {msg}")
else:
lines.append(f" `{ts}` {ev_icon}{ev.event_type.value}")
lines.append("")
lines.append("```")
return "\n".join(lines)
def render_run_summary(idx: int, run: V1TaskSummary) -> str:
"""One-line summary for a run in the list view."""
icon = STATUS_ICON.get(run.status, "?")
name = run.display_name or run.workflow_name or "?"
run_id = run.workflow_run_external_id or "?"
dur = _fmt_duration(run.duration)
started = _fmt_time(run.started_at)
meta = ""
if run.additional_metadata:
meta_parts = [f"{k}=`{v}`" for k, v in run.additional_metadata.items()]
meta = f" ({', '.join(meta_parts)})"
return (
f" {idx}. {icon} **{name}** started={started} dur={dur}{meta}\n"
f" `{run_id}`"
)
async def _fetch_run_list(
count: int = 5,
statuses: list[V1TaskStatus] | None = None,
) -> list[V1TaskSummary]:
client = HatchetClientManager.get_client()
since = datetime.now(timezone.utc) - timedelta(days=7)
runs = await client.runs.aio_list(
since=since,
statuses=statuses,
limit=count,
)
return runs.rows or []
async def list_recent_runs(
count: int = 5,
statuses: list[V1TaskStatus] | None = None,
) -> str:
"""List recent workflow runs as text."""
rows = await _fetch_run_list(count, statuses)
if not rows:
return "No runs found in the last 7 days."
lines = [f"Recent runs ({len(rows)}):", ""]
for i, run in enumerate(rows, 1):
lines.append(render_run_summary(i, run))
lines.append("")
lines.append("Use `--show N` to see full DAG for run N")
return "\n".join(lines)
async def show_run(workflow_run_id: str) -> str:
"""Fetch and render a single run."""
client = HatchetClientManager.get_client()
details = await client.runs.aio_get(workflow_run_id)
return render_run_detail(details)
async def show_nth_run(
n: int,
count: int = 5,
statuses: list[V1TaskStatus] | None = None,
) -> str:
"""Fetch list, then drill into Nth run."""
rows = await _fetch_run_list(count, statuses)
if not rows:
return "No runs found in the last 7 days."
if n < 1 or n > len(rows):
return f"Invalid index {n}. Have {len(rows)} runs (1-{len(rows)})."
run = rows[n - 1]
return await show_run(run.workflow_run_external_id)
async def main_async(args: argparse.Namespace) -> None:
statuses = [V1TaskStatus(args.status)] if args.status else None
if args.run_id:
output = await show_run(args.run_id)
elif args.show is not None:
output = await show_nth_run(args.show, count=args.last, statuses=statuses)
else:
output = await list_recent_runs(count=args.last, statuses=statuses)
print(output)
def main() -> None:
parser = argparse.ArgumentParser(
description="Render Hatchet workflow runs as text DAG"
)
parser.add_argument(
"run_id",
nargs="?",
default=None,
help="Workflow run ID to show in detail. If omitted, lists recent runs.",
)
parser.add_argument(
"--show",
type=int,
default=None,
metavar="N",
help="Show full DAG for the Nth run in the list (1-indexed)",
)
parser.add_argument(
"--last",
type=int,
default=5,
help="Number of recent runs to list (default: 5)",
)
parser.add_argument(
"--status",
choices=["QUEUED", "RUNNING", "COMPLETED", "FAILED", "CANCELLED"],
help="Filter by status",
)
args = parser.parse_args()
asyncio.run(main_async(args))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from pydantic import BaseModel
from reflector.email import is_email_configured
from reflector.settings import settings
router = APIRouter()
class ConfigResponse(BaseModel):
zulip_enabled: bool
email_enabled: bool
@router.get("/config", response_model=ConfigResponse)
async def get_config():
return ConfigResponse(
zulip_enabled=bool(settings.ZULIP_REALM),
email_enabled=is_email_configured(),
)

View File

@@ -4,7 +4,7 @@ from typing import Annotated, Any, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
import reflector.auth as auth
from reflector.dailyco_api import RecordingType
@@ -89,14 +89,16 @@ class StartRecordingRequest(BaseModel):
@router.post("/meetings/{meeting_id}/recordings/start")
async def start_recording(
meeting_id: NonEmptyString, body: StartRecordingRequest
meeting_id: NonEmptyString,
body: StartRecordingRequest,
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
) -> dict[str, Any]:
"""Start cloud or raw-tracks recording via Daily.co REST API.
Both cloud and raw-tracks are started via REST API to bypass enable_recording limitation of allowing only 1 recording at a time.
Uses different instanceIds for cloud vs raw-tracks (same won't work)
Note: No authentication required - anonymous users supported. TODO this is a DOS vector
"""
meeting = await meetings_controller.get_by_id(meeting_id)
if not meeting:
@@ -149,3 +151,26 @@ async def start_recording(
raise HTTPException(
status_code=500, detail=f"Failed to start recording: {str(e)}"
)
class AddEmailRecipientRequest(BaseModel):
email: EmailStr
@router.post("/meetings/{meeting_id}/email-recipient")
async def add_email_recipient(
meeting_id: str,
request: AddEmailRecipientRequest,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
"""Add an email address to receive the transcript link when processing completes."""
meeting = await meetings_controller.get_by_id(meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
include_link = user is not None
recipients = await meetings_controller.add_email_recipient(
meeting_id, request.email, include_link=include_link
)
return {"status": "success", "email_recipients": recipients}

View File

@@ -17,7 +17,6 @@ from reflector.db.rooms import rooms_controller
from reflector.redis_cache import RedisAsyncLock
from reflector.schemas.platform import Platform
from reflector.services.ics_sync import ics_sync_service
from reflector.settings import settings
from reflector.utils.url import add_query_param
from reflector.video_platforms.factory import create_platform_client
from reflector.worker.webhook import test_webhook
@@ -45,6 +44,7 @@ class Room(BaseModel):
ics_last_etag: Optional[str] = None
platform: Platform
skip_consent: bool = False
email_transcript_to: str | None = None
class RoomDetails(Room):
@@ -94,6 +94,7 @@ class CreateRoom(BaseModel):
ics_enabled: bool = False
platform: Platform
skip_consent: bool = False
email_transcript_to: str | None = None
class UpdateRoom(BaseModel):
@@ -113,6 +114,7 @@ class UpdateRoom(BaseModel):
ics_enabled: Optional[bool] = None
platform: Optional[Platform] = None
skip_consent: Optional[bool] = None
email_transcript_to: Optional[str] = None
class CreateRoomMeeting(BaseModel):
@@ -178,11 +180,10 @@ router = APIRouter()
@router.get("/rooms", response_model=Page[RoomDetails])
async def rooms_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
) -> list[RoomDetails]:
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None
paginated = await apaginate(
@@ -255,6 +256,7 @@ async def rooms_create(
ics_enabled=room.ics_enabled,
platform=room.platform,
skip_consent=room.skip_consent,
email_transcript_to=room.email_transcript_to,
)

View File

@@ -16,6 +16,7 @@ from pydantic import (
import reflector.auth as auth
from reflector.db import get_database
from reflector.db.meetings import meetings_controller
from reflector.db.recordings import recordings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.search import (
@@ -39,6 +40,7 @@ from reflector.db.transcripts import (
transcripts_controller,
)
from reflector.db.users import user_controller
from reflector.email import is_email_configured, send_transcript_email
from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.processors.types import Word
from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment
@@ -112,6 +114,8 @@ class GetTranscriptMinimal(BaseModel):
room_name: str | None = None
audio_deleted: bool | None = None
change_seq: int | None = None
has_cloud_video: bool = False
cloud_video_duration: int | None = None
class TranscriptParticipantWithEmail(TranscriptParticipant):
@@ -263,16 +267,15 @@ class SearchResponse(BaseModel):
@router.get("/transcripts", response_model=Page[GetTranscriptMinimal])
async def transcripts_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
source_kind: SourceKind | None = None,
room_id: str | None = None,
search_term: str | None = None,
change_seq_from: int | None = None,
sort_by: Literal["created_at", "change_seq"] | None = None,
):
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None
# Default behavior preserved: sort_by=None → "-created_at"
@@ -306,16 +309,20 @@ async def transcripts_search(
source_kind: Optional[SourceKind] = None,
from_datetime: SearchFromDatetimeParam = None,
to_datetime: SearchToDatetimeParam = None,
include_deleted: bool = False,
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional)
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
] = None,
):
"""Full-text search across transcript titles and content."""
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None
if include_deleted and not user_id:
raise HTTPException(
status_code=401,
detail="Authentication required to view deleted transcripts",
)
if from_datetime and to_datetime and from_datetime > to_datetime:
raise HTTPException(
status_code=400, detail="'from' must be less than or equal to 'to'"
@@ -330,6 +337,7 @@ async def transcripts_search(
source_kind=source_kind,
from_datetime=from_datetime,
to_datetime=to_datetime,
include_deleted=include_deleted,
)
results, total = await search_controller.search_transcripts(search_params)
@@ -346,7 +354,9 @@ async def transcripts_search(
@router.post("/transcripts", response_model=GetTranscriptWithParticipants)
async def transcripts_create(
info: CreateTranscript,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
):
user_id = user["sub"] if user else None
transcript = await transcripts_controller.add(
@@ -503,6 +513,14 @@ async def transcript_get(
)
)
has_cloud_video = False
cloud_video_duration = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if meeting and meeting.daily_composed_video_s3_key:
has_cloud_video = True
cloud_video_duration = meeting.daily_composed_video_duration
base_data = {
"id": transcript.id,
"user_id": transcript.user_id,
@@ -526,6 +544,8 @@ async def transcript_get(
"audio_deleted": transcript.audio_deleted,
"change_seq": transcript.change_seq,
"participants": participants,
"has_cloud_video": has_cloud_video,
"cloud_video_duration": cloud_video_duration,
}
if transcript_format == "text":
@@ -603,6 +623,54 @@ async def transcript_delete(
return DeletionStatus(status="ok")
@router.post("/transcripts/{transcript_id}/restore", response_model=DeletionStatus)
async def transcript_restore(
transcript_id: str,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
"""Restore a soft-deleted transcript."""
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if transcript.deleted_at is None:
raise HTTPException(status_code=400, detail="Transcript is not deleted")
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.restore_by_id(transcript.id, user_id=user_id)
await get_ws_manager().send_json(
room_id=f"user:{user_id}",
message={"event": "TRANSCRIPT_RESTORED", "data": {"id": transcript.id}},
)
return DeletionStatus(status="ok")
@router.delete("/transcripts/{transcript_id}/destroy", response_model=DeletionStatus)
async def transcript_destroy(
transcript_id: str,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
"""Permanently delete a transcript and all associated files."""
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if transcript.deleted_at is None:
raise HTTPException(
status_code=400, detail="Transcript must be soft-deleted first"
)
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.hard_delete(transcript.id)
await get_ws_manager().send_json(
room_id=f"user:{user_id}",
message={"event": "TRANSCRIPT_DELETED", "data": {"id": transcript.id}},
)
return DeletionStatus(status="ok")
@router.get(
"/transcripts/{transcript_id}/topics",
response_model=list[GetTranscriptTopic],
@@ -688,8 +756,6 @@ async def transcript_post_to_zulip(
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
content = get_zulip_message(transcript, include_topics)
message_updated = False
@@ -707,3 +773,31 @@ async def transcript_post_to_zulip(
await transcripts_controller.update(
transcript, {"zulip_message_id": response["id"]}
)
class SendEmailRequest(BaseModel):
email: str
class SendEmailResponse(BaseModel):
sent: int
@router.post("/transcripts/{transcript_id}/email", response_model=SendEmailResponse)
async def transcript_send_email(
transcript_id: str,
request: SendEmailRequest,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
if not is_email_configured():
raise HTTPException(status_code=400, detail="Email not configured")
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
sent = await send_transcript_email(
[request.email], transcript, include_link=(transcript.share_mode == "public")
)
return SendEmailResponse(sent=sent)

View File

@@ -53,9 +53,22 @@ async def transcript_get_audio_mp3(
else:
user_id = token_user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not user_id and not token:
# No authentication provided at all. Only anonymous transcripts
# (user_id=None) are accessible without auth, to preserve
# pipeline access via _generate_local_audio_link().
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript or transcript.deleted_at is not None:
raise HTTPException(status_code=404, detail="Transcript not found")
if transcript.user_id is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
else:
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.audio_location == "storage":
# proxy S3 file, to prevent issue with CORS
@@ -94,16 +107,16 @@ async def transcript_get_audio_mp3(
request,
transcript.audio_mp3_filename,
content_type="audio/mpeg",
content_disposition=f"attachment; filename={filename}",
content_disposition=f"inline; filename={filename}",
)
@router.get("/transcripts/{transcript_id}/audio/waveform")
async def transcript_get_audio_waveform(
transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> AudioWaveform:
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)

View File

@@ -0,0 +1,169 @@
"""
Transcript download endpoint — generates a zip archive with all transcript files.
"""
import json
import os
import tempfile
import zipfile
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
import reflector.auth as auth
from reflector.db.meetings import meetings_controller
from reflector.db.recordings import recordings_controller
from reflector.db.transcripts import transcripts_controller
from reflector.logger import logger
from reflector.storage import get_source_storage, get_transcripts_storage
router = APIRouter()
@router.get(
"/transcripts/{transcript_id}/download/zip",
operation_id="transcript_download_zip",
)
async def transcript_download_zip(
transcript_id: str,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
recording = None
if transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
truncated_id = str(transcript.id).split("-")[0]
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = os.path.join(tmpdir, f"transcript_{truncated_id}.zip")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
# Transcript audio
if transcript.audio_location == "storage" and not transcript.audio_deleted:
try:
storage = get_transcripts_storage()
data = await storage.get_file(transcript.storage_audio_path)
audio_path = os.path.join(tmpdir, "audio.mp3")
with open(audio_path, "wb") as f:
f.write(data)
zf.write(audio_path, "audio.mp3")
except Exception as e:
logger.warning(
"Failed to download transcript audio for zip",
exc_info=e,
transcript_id=transcript.id,
)
elif (
not transcript.audio_deleted
and hasattr(transcript, "audio_mp3_filename")
and transcript.audio_mp3_filename
and transcript.audio_mp3_filename.exists()
):
zf.write(str(transcript.audio_mp3_filename), "audio.mp3")
# Recording tracks (multitrack)
if recording and recording.track_keys:
try:
source_storage = get_source_storage(
"daily" if recording.track_keys else None
)
except Exception:
source_storage = get_transcripts_storage()
for i, key in enumerate(recording.track_keys):
try:
data = await source_storage.get_file(
key, bucket=recording.bucket_name
)
filename = os.path.basename(key) or f"track_{i}"
track_path = os.path.join(tmpdir, f"track_{i}")
with open(track_path, "wb") as f:
f.write(data)
zf.write(track_path, f"tracks/{filename}")
except Exception as e:
logger.warning(
"Failed to download track for zip",
exc_info=e,
track_key=key,
)
# Cloud video
if meeting and meeting.daily_composed_video_s3_key:
try:
source_storage = get_source_storage("daily")
data = await source_storage.get_file(
meeting.daily_composed_video_s3_key
)
video_path = os.path.join(tmpdir, "cloud_video.mp4")
with open(video_path, "wb") as f:
f.write(data)
zf.write(video_path, "cloud_video.mp4")
except Exception as e:
logger.warning(
"Failed to download cloud video for zip",
exc_info=e,
s3_key=meeting.daily_composed_video_s3_key,
)
# Metadata JSON
metadata = {
"id": transcript.id,
"title": transcript.title,
"created_at": (
transcript.created_at.isoformat() if transcript.created_at else None
),
"duration": transcript.duration,
"source_language": transcript.source_language,
"target_language": transcript.target_language,
"short_summary": transcript.short_summary,
"long_summary": transcript.long_summary,
"topics": (
[t.model_dump() for t in transcript.topics]
if transcript.topics
else []
),
"participants": (
[p.model_dump() for p in transcript.participants]
if transcript.participants
else []
),
"action_items": transcript.action_items,
"webvtt": transcript.webvtt,
"recording_id": transcript.recording_id,
"meeting_id": transcript.meeting_id,
}
meta_path = os.path.join(tmpdir, "metadata.json")
with open(meta_path, "w") as f:
json.dump(metadata, f, indent=2, default=str)
zf.write(meta_path, "metadata.json")
# Read zip into memory before tmpdir is cleaned up
with open(zip_path, "rb") as f:
zip_bytes = f.read()
def iter_zip():
offset = 0
chunk_size = 64 * 1024
while offset < len(zip_bytes):
yield zip_bytes[offset : offset + chunk_size]
offset += chunk_size
return StreamingResponse(
iter_zip(),
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename=transcript_{truncated_id}.zip"
},
)

View File

@@ -62,8 +62,7 @@ async def transcript_add_participant(
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
transcripts_controller.check_can_mutate(transcript, user_id)
# ensure the speaker is unique
if participant.speaker is not None and transcript.participants is not None:
@@ -109,8 +108,7 @@ async def transcript_update_participant(
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
transcripts_controller.check_can_mutate(transcript, user_id)
# ensure the speaker is unique
for p in transcript.participants:
@@ -148,7 +146,6 @@ async def transcript_delete_participant(
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
transcripts_controller.check_can_mutate(transcript, user_id)
await transcripts_controller.delete_participant(transcript, participant_id)
return DeletionStatus(status="ok")

View File

@@ -26,7 +26,9 @@ class ProcessStatus(BaseModel):
@router.post("/transcripts/{transcript_id}/process")
async def transcript_process(
transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
) -> ProcessStatus:
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(
@@ -50,8 +52,5 @@ async def transcript_process(
if isinstance(config, ProcessError):
raise HTTPException(status_code=500, detail=config.detail)
else:
# When transcript is in error state, force a new workflow instead of replaying
# (replay would re-run from failure point with same conditions and likely fail again)
force = transcript.status == "error"
await dispatch_transcript_processing(config, force=force)
await dispatch_transcript_processing(config, force=True)
return ProcessStatus(status="ok")

View File

@@ -41,8 +41,7 @@ async def transcript_assign_speaker(
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
transcripts_controller.check_can_mutate(transcript, user_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
@@ -121,8 +120,7 @@ async def transcript_merge_speaker(
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
transcripts_controller.check_can_mutate(transcript, user_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
from reflector.hatchet.client import HatchetClientManager
router = APIRouter()
@@ -21,7 +21,9 @@ async def transcript_record_upload(
chunk_number: int,
total_chunks: int,
chunk: UploadFile,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
):
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(
@@ -93,7 +95,14 @@ async def transcript_record_upload(
transcript, {"status": "uploaded", "source_kind": SourceKind.FILE}
)
# launch a background task to process the file
task_pipeline_file_process.delay(transcript_id=transcript_id)
# launch Hatchet workflow to process the file
workflow_id = await HatchetClientManager.start_workflow(
"FilePipeline",
{"transcript_id": str(transcript_id)},
additional_metadata={"transcript_id": str(transcript_id)},
)
# Save workflow_run_id for duplicate detection and status polling
await transcripts_controller.update(transcript, {"workflow_run_id": workflow_id})
return UploadStatus(status="ok")

View File

@@ -0,0 +1,60 @@
"""
Transcript cloud video endpoint — returns a presigned URL for streaming playback.
"""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.meetings import meetings_controller
from reflector.db.transcripts import transcripts_controller
from reflector.storage import get_source_storage
router = APIRouter()
class VideoUrlResponse(BaseModel):
url: str
duration: int | None = None
content_type: str = "video/mp4"
@router.get(
"/transcripts/{transcript_id}/video/url",
operation_id="transcript_get_video_url",
response_model=VideoUrlResponse,
)
async def transcript_get_video_url(
transcript_id: str,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript.meeting_id:
raise HTTPException(status_code=404, detail="No video available")
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if not meeting or not meeting.daily_composed_video_s3_key:
raise HTTPException(status_code=404, detail="No video available")
source_storage = get_source_storage("daily")
url = await source_storage.get_file_url(
meeting.daily_composed_video_s3_key,
operation="get_object",
expires_in=900,
extra_params={
"ResponseContentDisposition": "inline",
"ResponseContentType": "video/mp4",
},
)
return VideoUrlResponse(
url=url,
duration=meeting.daily_composed_video_duration,
)

View File

@@ -15,7 +15,9 @@ async def transcript_record_webrtc(
transcript_id: str,
params: RtcOffer,
request: Request,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional_if_public_mode)
],
):
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(

View File

@@ -146,7 +146,6 @@ else:
app.conf.broker_connection_retry_on_startup = True
app.autodiscover_tasks(
[
"reflector.pipelines.main_live_pipeline",
"reflector.worker.healthcheck",
"reflector.worker.process",
"reflector.worker.cleanup",

View File

@@ -90,7 +90,9 @@ async def cleanup_old_transcripts(
):
"""Delete old anonymous transcripts and their associated recordings/meetings."""
query = transcripts.select().where(
(transcripts.c.created_at < cutoff_date) & (transcripts.c.user_id.is_(None))
(transcripts.c.created_at < cutoff_date)
& (transcripts.c.user_id.is_(None))
& (transcripts.c.deleted_at.is_(None))
)
old_transcripts = await db.fetch_all(query)

View File

@@ -12,6 +12,7 @@ from celery import shared_task
from celery.utils.log import get_task_logger
from pydantic import ValidationError
from reflector.asynctask import asynctask
from reflector.dailyco_api import FinishedRecordingResponse, RecordingResponse
from reflector.db.daily_participant_sessions import (
DailyParticipantSession,
@@ -25,10 +26,6 @@ from reflector.db.transcripts import (
transcripts_controller,
)
from reflector.hatchet.client import HatchetClientManager
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
from reflector.pipelines.main_live_pipeline import asynctask
from reflector.pipelines.topic_processing import EmptyPipeline
from reflector.processors import AudioFileWriterProcessor
from reflector.processors.audio_waveform_processor import AudioWaveformProcessor
from reflector.redis_cache import RedisAsyncLock
from reflector.settings import settings
@@ -105,6 +102,12 @@ async def process_recording(bucket_name: str, object_key: str):
room = await rooms_controller.get_by_id(meeting.room_id)
recording = await recordings_controller.get_by_object_key(bucket_name, object_key)
if recording and recording.deleted_at is not None:
logger.info(
"Skipping soft-deleted recording",
recording_id=recording.id,
)
return
if not recording:
recording = await recordings_controller.create(
Recording(
@@ -116,6 +119,13 @@ async def process_recording(bucket_name: str, object_key: str):
)
transcript = await transcripts_controller.get_by_recording_id(recording.id)
if transcript and transcript.deleted_at is not None:
logger.info(
"Skipping soft-deleted transcript for recording",
recording_id=recording.id,
transcript_id=transcript.id,
)
return
if transcript:
await transcripts_controller.update(
transcript,
@@ -132,7 +142,7 @@ async def process_recording(bucket_name: str, object_key: str):
target_language="en",
user_id=room.user_id,
recording_id=recording.id,
share_mode="public",
share_mode="semi-private",
meeting_id=meeting.id,
room_id=room.id,
)
@@ -163,7 +173,14 @@ async def process_recording(bucket_name: str, object_key: str):
await transcripts_controller.update(transcript, {"status": "uploaded"})
task_pipeline_file_process.delay(transcript_id=transcript.id)
await HatchetClientManager.start_workflow(
"FilePipeline",
{
"transcript_id": str(transcript.id),
"room_id": str(room.id) if room else None,
},
additional_metadata={"transcript_id": str(transcript.id)},
)
@shared_task
@@ -256,6 +273,13 @@ async def _process_multitrack_recording_inner(
# Check if recording already exists (reprocessing path)
recording = await recordings_controller.get_by_id(recording_id)
if recording and recording.deleted_at is not None:
logger.info(
"Skipping soft-deleted recording",
recording_id=recording_id,
)
return
if recording and recording.meeting_id:
# Reprocessing: recording exists with meeting already linked
meeting = await meetings_controller.get_by_id(recording.meeting_id)
@@ -335,6 +359,13 @@ async def _process_multitrack_recording_inner(
)
transcript = await transcripts_controller.get_by_recording_id(recording.id)
if transcript and transcript.deleted_at is not None:
logger.info(
"Skipping soft-deleted transcript for recording",
recording_id=recording.id,
transcript_id=transcript.id,
)
return
if not transcript:
transcript = await transcripts_controller.add(
"",
@@ -343,7 +374,7 @@ async def _process_multitrack_recording_inner(
target_language="en",
user_id=room.user_id,
recording_id=recording.id,
share_mode="public",
share_mode="semi-private",
meeting_id=meeting.id,
room_id=room.id,
)
@@ -875,6 +906,11 @@ async def convert_audio_and_waveform(transcript) -> None:
transcript_id=transcript.id,
)
from reflector.pipelines.topic_processing import EmptyPipeline # noqa: PLC0415
from reflector.processors.audio_file_writer import (
AudioFileWriterProcessor, # noqa: PLC0415
)
upload_path = transcript.data_path / "upload.webm"
mp3_path = transcript.audio_mp3_filename

View File

@@ -8,8 +8,8 @@ import structlog
from celery import shared_task
from celery.utils.log import get_task_logger
from reflector.asynctask import asynctask
from reflector.db.rooms import rooms_controller
from reflector.pipelines.main_live_pipeline import asynctask
from reflector.utils.webhook import (
WebhookRoomPayload,
WebhookTestPayload,

View File

@@ -113,6 +113,7 @@ TranscriptWsEvent = Annotated[
UserEventName = Literal[
"TRANSCRIPT_CREATED",
"TRANSCRIPT_DELETED",
"TRANSCRIPT_RESTORED",
"TRANSCRIPT_STATUS",
"TRANSCRIPT_FINAL_TITLE",
"TRANSCRIPT_DURATION",
@@ -161,6 +162,15 @@ class UserWsTranscriptDeleted(BaseModel):
data: UserTranscriptDeletedData
class UserTranscriptRestoredData(BaseModel):
id: NonEmptyString
class UserWsTranscriptRestored(BaseModel):
event: Literal["TRANSCRIPT_RESTORED"] = "TRANSCRIPT_RESTORED"
data: UserTranscriptRestoredData
class UserWsTranscriptStatus(BaseModel):
event: Literal["TRANSCRIPT_STATUS"] = "TRANSCRIPT_STATUS"
data: UserTranscriptStatusData
@@ -180,6 +190,7 @@ UserWsEvent = Annotated[
Union[
UserWsTranscriptCreated,
UserWsTranscriptDeleted,
UserWsTranscriptRestored,
UserWsTranscriptStatus,
UserWsTranscriptFinalTitle,
UserWsTranscriptDuration,

View File

@@ -107,7 +107,8 @@ class WebsocketManager:
while True:
# timeout=1.0 prevents tight CPU loop when no messages available
message = await pubsub_subscriber.get_message(
ignore_subscribe_messages=True
ignore_subscribe_messages=True,
timeout=1.0,
)
if message is not None:
room_id = message["channel"].decode("utf-8")

View File

@@ -1,6 +1,6 @@
import os
from contextlib import asynccontextmanager
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -437,6 +437,8 @@ async def ws_manager_in_memory(monkeypatch):
try:
fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None
# current_user_optional_if_public_mode is NOT overridden here so the real
# implementation runs and enforces the PUBLIC_MODE check during tests.
except Exception:
pass
@@ -491,37 +493,39 @@ async def authenticated_client2():
@asynccontextmanager
async def authenticated_client_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
from reflector.auth import (
current_user,
current_user_optional,
current_user_optional_if_public_mode,
)
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
_user = lambda: {"sub": "randomuserid", "email": "test@mail.com"}
app.dependency_overrides[current_user] = _user
app.dependency_overrides[current_user_optional] = _user
app.dependency_overrides[current_user_optional_if_public_mode] = _user
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
del app.dependency_overrides[current_user_optional_if_public_mode]
@asynccontextmanager
async def authenticated_client2_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
from reflector.auth import (
current_user,
current_user_optional,
current_user_optional_if_public_mode,
)
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
_user = lambda: {"sub": "randomuserid2", "email": "test@mail.com"}
app.dependency_overrides[current_user] = _user
app.dependency_overrides[current_user_optional] = _user
app.dependency_overrides[current_user_optional_if_public_mode] = _user
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
del app.dependency_overrides[current_user_optional_if_public_mode]
@pytest.fixture(scope="session")
@@ -534,23 +538,64 @@ def fake_mp3_upload():
@pytest.fixture(autouse=True)
def reset_hatchet_client():
"""Reset HatchetClientManager singleton before and after each test.
def mock_hatchet_client():
"""Mock HatchetClientManager for all tests.
This ensures test isolation - each test starts with a fresh client state.
The fixture is autouse=True so it applies to all tests automatically.
Prevents tests from connecting to a real Hatchet server. The dummy token
in [tool.pytest_env] prevents the import-time ValueError, but the SDK
would still try to connect when get_client() is called. This fixture
mocks get_client to return a MagicMock and start_workflow to return a
dummy workflow ID.
"""
from reflector.hatchet.client import HatchetClientManager
# Reset before test
HatchetClientManager.reset()
yield
# Reset after test to clean up
mock_client = MagicMock()
mock_client.workflow.return_value = MagicMock()
with (
patch.object(
HatchetClientManager,
"get_client",
return_value=mock_client,
),
patch.object(
HatchetClientManager,
"start_workflow",
new_callable=AsyncMock,
return_value="mock-workflow-id",
),
patch.object(
HatchetClientManager,
"get_workflow_run_status",
new_callable=AsyncMock,
return_value=None,
),
patch.object(
HatchetClientManager,
"can_replay",
new_callable=AsyncMock,
return_value=False,
),
patch.object(
HatchetClientManager,
"cancel_workflow",
new_callable=AsyncMock,
),
patch.object(
HatchetClientManager,
"replay_workflow",
new_callable=AsyncMock,
),
):
yield mock_client
HatchetClientManager.reset()
@pytest.fixture
async def fake_transcript_with_topics(tmpdir, client):
async def fake_transcript_with_topics(tmpdir, client, monkeypatch):
import shutil
from pathlib import Path
@@ -559,6 +604,9 @@ async def fake_transcript_with_topics(tmpdir, client):
from reflector.settings import settings
from reflector.views.transcripts import transcripts_controller
monkeypatch.setattr(
settings, "PUBLIC_MODE", True
) # public mode: allow anonymous transcript creation for this test
settings.DATA_DIR = Path(tmpdir)
# create a transcript

View File

@@ -0,0 +1,234 @@
# Integration test stack — full pipeline end-to-end.
#
# Usage:
# docker compose -f server/tests/docker-compose.integration.yml up -d --build
#
# Requires .env.integration in the repo root (generated by CI workflow).
x-backend-env: &backend-env
DATABASE_URL: postgresql+asyncpg://reflector:reflector@postgres:5432/reflector
REDIS_HOST: redis
CELERY_BROKER_URL: redis://redis:6379/1
CELERY_RESULT_BACKEND: redis://redis:6379/1
HATCHET_CLIENT_TOKEN: ${HATCHET_CLIENT_TOKEN:-}
HATCHET_CLIENT_SERVER_URL: http://hatchet:8888
HATCHET_CLIENT_HOST_PORT: hatchet:7077
HATCHET_CLIENT_TLS_STRATEGY: none
# ML backends — CPU-only, no external services
TRANSCRIPT_BACKEND: whisper
WHISPER_CHUNK_MODEL: tiny
WHISPER_FILE_MODEL: tiny
DIARIZATION_BACKEND: pyannote
TRANSLATION_BACKEND: passthrough
# Storage — local Garage S3
TRANSCRIPT_STORAGE_BACKEND: aws
TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL: http://garage:3900
TRANSCRIPT_STORAGE_AWS_BUCKET_NAME: reflector-media
TRANSCRIPT_STORAGE_AWS_REGION: garage
# Daily mock
DAILY_API_URL: http://mock-daily:8080/v1
DAILY_API_KEY: fake-daily-key
# Auth
PUBLIC_MODE: "true"
AUTH_BACKEND: none
# LLM (injected from CI)
LLM_URL: ${LLM_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini}
# HuggingFace (for pyannote gated models)
HF_TOKEN: ${HF_TOKEN:-}
# Garage S3 credentials — hardcoded test keys, containers are ephemeral
TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: GK0123456789abcdef01234567 # gitleaks:allow
TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # gitleaks:allow
# Email / SMTP — Mailpit captures emails without sending
SMTP_HOST: mailpit
SMTP_PORT: "1025"
SMTP_FROM_EMAIL: test@reflector.local
SMTP_USE_TLS: "false"
# NOTE: DAILYCO_STORAGE_AWS_* intentionally NOT set — forces fallback to
# get_transcripts_storage() which has ENDPOINT_URL pointing at Garage.
# Setting them would bypass the endpoint and generate presigned URLs for AWS.
services:
postgres:
image: postgres:17-alpine
command: ["postgres", "-c", "max_connections=200"]
environment:
POSTGRES_USER: reflector
POSTGRES_PASSWORD: reflector
POSTGRES_DB: reflector
volumes:
- ../../server/docker/init-hatchet-db.sql:/docker-entrypoint-initdb.d/init-hatchet-db.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reflector"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7.2-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
hatchet:
image: ghcr.io/hatchet-dev/hatchet/hatchet-lite:latest
depends_on:
postgres:
condition: service_healthy
environment:
DATABASE_URL: "postgresql://reflector:reflector@postgres:5432/hatchet?sslmode=disable&connect_timeout=30"
SERVER_AUTH_COOKIE_INSECURE: "t"
SERVER_AUTH_COOKIE_DOMAIN: "localhost"
SERVER_GRPC_BIND_ADDRESS: "0.0.0.0"
SERVER_GRPC_INSECURE: "t"
SERVER_GRPC_BROADCAST_ADDRESS: hatchet:7077
SERVER_GRPC_PORT: "7077"
SERVER_AUTH_SET_EMAIL_VERIFIED: "t"
SERVER_INTERNAL_CLIENT_INTERNAL_GRPC_BROADCAST_ADDRESS: hatchet:7077
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/api/live"]
interval: 10s
timeout: 5s
retries: 15
start_period: 30s
garage:
image: dxflrs/garage:v1.1.0
volumes:
- ./integration/garage.toml:/etc/garage.toml:ro
healthcheck:
test: ["CMD", "/garage", "stats"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
mailpit:
image: axllent/mailpit:latest
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025/api/v1/messages"]
interval: 5s
timeout: 3s
retries: 5
mock-daily:
build:
context: .
dockerfile: integration/Dockerfile.mock-daily
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/v1/recordings/test')"]
interval: 5s
timeout: 3s
retries: 5
server:
build:
context: ../../server
dockerfile: Dockerfile
environment:
<<: *backend-env
ENTRYPOINT: server
WEBRTC_HOST: server
WEBRTC_PORT_RANGE: "52000-52100"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
hatchet:
condition: service_healthy
garage:
condition: service_healthy
mock-daily:
condition: service_healthy
mailpit:
condition: service_healthy
volumes:
- server_data:/app/data
worker:
build:
context: ../../server
dockerfile: Dockerfile
environment:
<<: *backend-env
ENTRYPOINT: worker
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- server_data:/app/data
hatchet-worker-cpu:
build:
context: ../../server
dockerfile: Dockerfile
environment:
<<: *backend-env
ENTRYPOINT: hatchet-worker-cpu
depends_on:
hatchet:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- server_data:/app/data
hatchet-worker-llm:
build:
context: ../../server
dockerfile: Dockerfile
environment:
<<: *backend-env
ENTRYPOINT: hatchet-worker-llm
depends_on:
hatchet:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- server_data:/app/data
test-runner:
build:
context: ../../server
dockerfile: Dockerfile
environment:
<<: *backend-env
# Override DATABASE_URL for sync driver (used by direct DB access in tests)
DATABASE_URL_ASYNC: postgresql+asyncpg://reflector:reflector@postgres:5432/reflector
DATABASE_URL: postgresql+asyncpg://reflector:reflector@postgres:5432/reflector
SERVER_URL: http://server:1250
GARAGE_ENDPOINT: http://garage:3900
MAILPIT_URL: http://mailpit:8025
depends_on:
server:
condition: service_started
worker:
condition: service_started
hatchet-worker-cpu:
condition: service_started
hatchet-worker-llm:
condition: service_started
volumes:
- server_data:/app/data
# Mount test files into the container
- ./records:/app/tests/records:ro
- ./integration:/app/tests/integration:ro
entrypoint: ["sleep", "infinity"]
volumes:
server_data:
networks:
default:
attachable: true

View File

@@ -0,0 +1,9 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir fastapi uvicorn[standard]
WORKDIR /app
COPY integration/mock_daily_server.py /app/mock_daily_server.py
EXPOSE 8080
CMD ["uvicorn", "mock_daily_server:app", "--host", "0.0.0.0", "--port", "8080"]

View File

View File

@@ -0,0 +1,158 @@
"""
Integration test fixtures — no mocks, real services.
All services (PostgreSQL, Redis, Hatchet, Garage, server, workers) are
expected to be running via docker-compose.integration.yml.
"""
import asyncio
import os
from pathlib import Path
import boto3
import httpx
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine
SERVER_URL = os.environ.get("SERVER_URL", "http://server:1250")
GARAGE_ENDPOINT = os.environ.get("GARAGE_ENDPOINT", "http://garage:3900")
MAILPIT_URL = os.environ.get("MAILPIT_URL", "http://mailpit:8025")
DATABASE_URL = os.environ.get(
"DATABASE_URL_ASYNC",
os.environ.get(
"DATABASE_URL",
"postgresql+asyncpg://reflector:reflector@postgres:5432/reflector",
),
)
GARAGE_KEY_ID = os.environ.get("TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID", "")
GARAGE_KEY_SECRET = os.environ.get("TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY", "")
BUCKET_NAME = "reflector-media"
@pytest_asyncio.fixture
async def api_client():
"""HTTP client pointed at the running server."""
async with httpx.AsyncClient(
base_url=f"{SERVER_URL}/v1",
timeout=httpx.Timeout(30.0),
) as client:
yield client
@pytest.fixture(scope="session")
def s3_client():
"""Boto3 S3 client pointed at Garage."""
return boto3.client(
"s3",
endpoint_url=GARAGE_ENDPOINT,
aws_access_key_id=GARAGE_KEY_ID,
aws_secret_access_key=GARAGE_KEY_SECRET,
region_name="garage",
)
@pytest_asyncio.fixture
async def db_engine():
"""SQLAlchemy async engine for direct DB operations."""
engine = create_async_engine(DATABASE_URL)
yield engine
await engine.dispose()
@pytest.fixture(scope="session")
def test_records_dir():
"""Path to the test audio files directory."""
return Path(__file__).parent.parent / "records"
@pytest.fixture(scope="session")
def bucket_name():
"""S3 bucket name used for integration tests."""
return BUCKET_NAME
async def _poll_transcript_status(
client: httpx.AsyncClient,
transcript_id: str,
target: str | tuple[str, ...],
error: str = "error",
max_wait: int = 300,
interval: int = 3,
) -> dict:
"""
Poll GET /transcripts/{id} until status matches target or error.
target can be a single status string or a tuple of acceptable statuses.
Returns the transcript dict on success, raises on timeout or error status.
"""
targets = (target,) if isinstance(target, str) else target
elapsed = 0
status = None
while elapsed < max_wait:
resp = await client.get(f"/transcripts/{transcript_id}")
resp.raise_for_status()
data = resp.json()
status = data.get("status")
if status in targets:
return data
if status == error:
raise AssertionError(
f"Transcript {transcript_id} reached error status: {data}"
)
await asyncio.sleep(interval)
elapsed += interval
raise TimeoutError(
f"Transcript {transcript_id} did not reach status '{target}' "
f"within {max_wait}s (last status: {status})"
)
@pytest_asyncio.fixture
def poll_transcript_status():
"""Returns the poll_transcript_status async helper function."""
return _poll_transcript_status
@pytest_asyncio.fixture
async def mailpit_client():
"""HTTP client for Mailpit API — query captured emails."""
async with httpx.AsyncClient(
base_url=MAILPIT_URL,
timeout=httpx.Timeout(10.0),
) as client:
# Clear inbox before each test
await client.delete("/api/v1/messages")
yield client
async def _poll_mailpit_messages(
mailpit: httpx.AsyncClient,
to_email: str,
max_wait: int = 30,
interval: int = 2,
) -> list[dict]:
"""
Poll Mailpit API until at least one message is delivered to the given address.
Returns the list of matching messages.
"""
elapsed = 0
while elapsed < max_wait:
resp = await mailpit.get("/api/v1/messages", params={"query": f"to:{to_email}"})
resp.raise_for_status()
data = resp.json()
messages = data.get("messages", [])
if messages:
return messages
await asyncio.sleep(interval)
elapsed += interval
raise TimeoutError(f"No email delivered to {to_email} within {max_wait}s")
@pytest_asyncio.fixture
def poll_mailpit_messages():
"""Returns the poll_mailpit_messages async helper function."""
return _poll_mailpit_messages

View File

@@ -0,0 +1,14 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
replication_factor = 1
rpc_secret = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" # gitleaks:allow
rpc_bind_addr = "[::]:3901"
[s3_api]
api_bind_addr = "[::]:3900"
s3_region = "garage"
root_domain = ".s3.garage.localhost"
[admin]
api_bind_addr = "[::]:3903"

View File

@@ -0,0 +1,62 @@
#!/bin/sh
#
# Initialize Garage bucket and keys for integration tests.
# Run inside the Garage container after it's healthy.
#
# Outputs KEY_ID and KEY_SECRET to stdout (last two lines).
#
# Note: uses /bin/sh (not bash) since the Garage container is minimal.
#
set -eu
echo "Waiting for Garage to be ready..."
i=0
while [ "$i" -lt 30 ]; do
if /garage stats >/dev/null 2>&1; then
break
fi
sleep 1
i=$((i + 1))
done
# Layout setup
NODE_ID=$(/garage node id -q | tr -d '[:space:]')
LAYOUT_STATUS=$(/garage layout show 2>&1 || true)
if echo "$LAYOUT_STATUS" | grep -q "No nodes"; then
/garage layout assign "$NODE_ID" -c 1G -z dc1
/garage layout apply --version 1
echo "Layout applied."
else
echo "Layout already configured."
fi
# Bucket
if ! /garage bucket info reflector-media >/dev/null 2>&1; then
/garage bucket create reflector-media
echo "Bucket 'reflector-media' created."
else
echo "Bucket 'reflector-media' already exists."
fi
# Key
if /garage key info reflector-test >/dev/null 2>&1; then
echo "Key 'reflector-test' already exists."
KEY_OUTPUT=$(/garage key info reflector-test 2>&1)
else
KEY_OUTPUT=$(/garage key create reflector-test 2>&1)
echo "Key 'reflector-test' created."
fi
# Permissions
/garage bucket allow reflector-media --read --write --key reflector-test
# Extract key ID and secret from output using POSIX-compatible parsing
# garage key output format:
# Key name: reflector-test
# Key ID: GK...
# Secret key: ...
KEY_ID=$(echo "$KEY_OUTPUT" | grep "Key ID" | sed 's/.*Key ID: *//')
KEY_SECRET=$(echo "$KEY_OUTPUT" | grep "Secret key" | sed 's/.*Secret key: *//')
echo "GARAGE_KEY_ID=${KEY_ID}"
echo "GARAGE_KEY_SECRET=${KEY_SECRET}"

View File

@@ -0,0 +1,75 @@
"""
Minimal FastAPI mock for Daily.co API.
Serves canned responses for:
- GET /v1/recordings/{recording_id}
- GET /v1/meetings/{meeting_id}/participants
"""
from fastapi import FastAPI
app = FastAPI(title="Mock Daily API")
# Participant UUIDs must be 36-char hex UUIDs to match Daily's filename format
PARTICIPANT_A_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
PARTICIPANT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
# Daily-format track keys: {recording_start_ts}-{participant_id}-cam-audio-{track_start_ts}
TRACK_KEYS = [
f"1700000000000-{PARTICIPANT_A_ID}-cam-audio-1700000001000",
f"1700000000000-{PARTICIPANT_B_ID}-cam-audio-1700000001000",
]
@app.get("/v1/recordings/{recording_id}")
async def get_recording(recording_id: str):
return {
"id": recording_id,
"room_name": "integration-test-room",
"start_ts": 1700000000,
"type": "raw-tracks",
"status": "finished",
"max_participants": 2,
"duration": 5,
"share_token": None,
"s3": {
"bucket_name": "reflector-media",
"bucket_region": "garage",
"key": None,
"endpoint": None,
},
"s3key": None,
"tracks": [
{"type": "audio", "s3Key": key, "size": 100000} for key in TRACK_KEYS
],
"mtgSessionId": "mock-mtg-session-id",
}
@app.get("/v1/meetings/{meeting_id}/participants")
async def get_meeting_participants(meeting_id: str):
return {
"data": [
{
"user_id": "user-a",
"participant_id": PARTICIPANT_A_ID,
"user_name": "Speaker A",
"join_time": 1700000000,
"duration": 300,
},
{
"user_id": "user-b",
"participant_id": PARTICIPANT_B_ID,
"user_name": "Speaker B",
"join_time": 1700000010,
"duration": 290,
},
]
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)

View File

@@ -0,0 +1,61 @@
"""
Integration test: File upload → FilePipeline → full processing.
Exercises: upload endpoint → Hatchet FilePipeline → whisper transcription →
pyannote diarization → LLM summarization/topics → status "ended".
"""
import pytest
@pytest.mark.asyncio
async def test_file_pipeline_end_to_end(
api_client, test_records_dir, poll_transcript_status
):
"""Upload a WAV file and verify the full pipeline completes."""
# 1. Create transcript
resp = await api_client.post(
"/transcripts",
json={"name": "integration-file-test", "source_kind": "file"},
)
assert resp.status_code == 200, f"Failed to create transcript: {resp.text}"
transcript = resp.json()
transcript_id = transcript["id"]
# 2. Upload audio file (single chunk)
audio_path = test_records_dir / "test_short.wav"
assert audio_path.exists(), f"Test audio file not found: {audio_path}"
with open(audio_path, "rb") as f:
resp = await api_client.post(
f"/transcripts/{transcript_id}/record/upload",
params={"chunk_number": 0, "total_chunks": 1},
files={"chunk": ("test_short.wav", f, "audio/wav")},
)
assert resp.status_code == 200, f"Upload failed: {resp.text}"
# 3. Poll until pipeline completes
data = await poll_transcript_status(
api_client, transcript_id, target="ended", max_wait=300
)
# 4. Assertions
assert data["status"] == "ended"
assert data.get("title") and len(data["title"]) > 0, "Title should be non-empty"
assert (
data.get("long_summary") and len(data["long_summary"]) > 0
), "Long summary should be non-empty"
assert (
data.get("short_summary") and len(data["short_summary"]) > 0
), "Short summary should be non-empty"
# Topics are served from a separate endpoint
topics_resp = await api_client.get(f"/transcripts/{transcript_id}/topics")
assert topics_resp.status_code == 200, f"Failed to get topics: {topics_resp.text}"
topics = topics_resp.json()
assert len(topics) >= 1, "Should have at least 1 topic"
for topic in topics:
assert topic.get("title"), "Each topic should have a title"
assert topic.get("summary"), "Each topic should have a summary"
assert data.get("duration", 0) > 0, "Duration should be positive"

View File

@@ -0,0 +1,109 @@
"""
Integration test: WebRTC stream → LivePostProcessingPipeline → full processing.
Exercises: WebRTC SDP exchange → live audio streaming → connection close →
Hatchet LivePostPipeline → whisper transcription → LLM summarization/topics → status "ended".
"""
import asyncio
import json
import os
import httpx
import pytest
from aiortc import RTCPeerConnection, RTCSessionDescription
from aiortc.contrib.media import MediaPlayer
SERVER_URL = os.environ.get("SERVER_URL", "http://server:1250")
@pytest.mark.asyncio
async def test_live_pipeline_end_to_end(
api_client, test_records_dir, poll_transcript_status
):
"""Stream audio via WebRTC and verify the full post-processing pipeline completes."""
# 1. Create transcript
resp = await api_client.post(
"/transcripts",
json={"name": "integration-live-test"},
)
assert resp.status_code == 200, f"Failed to create transcript: {resp.text}"
transcript = resp.json()
transcript_id = transcript["id"]
# 2. Set up WebRTC peer connection with audio from test file
audio_path = test_records_dir / "test_short.wav"
assert audio_path.exists(), f"Test audio file not found: {audio_path}"
pc = RTCPeerConnection()
player = MediaPlayer(audio_path.as_posix())
# Add audio track
audio_track = player.audio
pc.addTrack(audio_track)
# Create data channel (server expects this for STOP command)
channel = pc.createDataChannel("data-channel")
# 3. Generate SDP offer
offer = await pc.createOffer()
await pc.setLocalDescription(offer)
sdp_payload = {
"sdp": pc.localDescription.sdp,
"type": pc.localDescription.type,
}
# 4. Send offer to server and get answer
webrtc_url = f"{SERVER_URL}/v1/transcripts/{transcript_id}/record/webrtc"
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.post(webrtc_url, json=sdp_payload)
assert resp.status_code == 200, f"WebRTC offer failed: {resp.text}"
answer_data = resp.json()
answer = RTCSessionDescription(sdp=answer_data["sdp"], type=answer_data["type"])
await pc.setRemoteDescription(answer)
# 5. Wait for audio playback to finish
max_stream_wait = 60
elapsed = 0
while elapsed < max_stream_wait:
if audio_track.readyState == "ended":
break
await asyncio.sleep(0.5)
elapsed += 0.5
# 6. Send STOP command and close connection
try:
channel.send(json.dumps({"cmd": "STOP"}))
await asyncio.sleep(1)
except Exception:
pass # Channel may not be open if track ended quickly
await pc.close()
# 7. Poll until post-processing pipeline completes
data = await poll_transcript_status(
api_client, transcript_id, target="ended", max_wait=300
)
# 8. Assertions
assert data["status"] == "ended"
assert data.get("title") and len(data["title"]) > 0, "Title should be non-empty"
assert (
data.get("long_summary") and len(data["long_summary"]) > 0
), "Long summary should be non-empty"
assert (
data.get("short_summary") and len(data["short_summary"]) > 0
), "Short summary should be non-empty"
# Topics are served from a separate endpoint
topics_resp = await api_client.get(f"/transcripts/{transcript_id}/topics")
assert topics_resp.status_code == 200, f"Failed to get topics: {topics_resp.text}"
topics = topics_resp.json()
assert len(topics) >= 1, "Should have at least 1 topic"
for topic in topics:
assert topic.get("title"), "Each topic should have a title"
assert topic.get("summary"), "Each topic should have a summary"
assert data.get("duration", 0) > 0, "Duration should be positive"

View File

@@ -0,0 +1,175 @@
"""
Integration test: Multitrack → DailyMultitrackPipeline → full processing.
Exercises: S3 upload → DB recording setup → process endpoint →
Hatchet DiarizationPipeline → mock Daily API → whisper per-track transcription →
diarization → mixdown → LLM summarization/topics → status "ended".
Also tests email transcript notification via Mailpit SMTP sink.
"""
import json
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import text
# Must match Daily's filename format: {recording_start_ts}-{participant_uuid}-cam-audio-{track_start_ts}
# These UUIDs must match mock_daily_server.py participant IDs
PARTICIPANT_A_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
PARTICIPANT_B_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
TRACK_KEYS = [
f"1700000000000-{PARTICIPANT_A_ID}-cam-audio-1700000001000",
f"1700000000000-{PARTICIPANT_B_ID}-cam-audio-1700000001000",
]
TEST_EMAIL = "integration-test@reflector.local"
@pytest.mark.asyncio
async def test_multitrack_pipeline_end_to_end(
api_client,
s3_client,
db_engine,
test_records_dir,
bucket_name,
poll_transcript_status,
mailpit_client,
poll_mailpit_messages,
):
"""Set up multitrack recording in S3/DB and verify the full pipeline completes."""
# 1. Upload test audio as two separate tracks to Garage S3
audio_path = test_records_dir / "test_short.wav"
assert audio_path.exists(), f"Test audio file not found: {audio_path}"
for track_key in TRACK_KEYS:
s3_client.upload_file(
str(audio_path),
bucket_name,
track_key,
)
# 2. Create transcript via API
resp = await api_client.post(
"/transcripts",
json={"name": "integration-multitrack-test"},
)
assert resp.status_code == 200, f"Failed to create transcript: {resp.text}"
transcript = resp.json()
transcript_id = transcript["id"]
# 3. Insert Meeting, Recording, and link to transcript via direct DB access
recording_id = f"rec-integration-{transcript_id[:8]}"
meeting_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
async with db_engine.begin() as conn:
# Insert meeting with email_recipients for email notification test
await conn.execute(
text("""
INSERT INTO meeting (
id, room_name, room_url, host_room_url,
start_date, end_date, platform, email_recipients
)
VALUES (
:id, :room_name, :room_url, :host_room_url,
:start_date, :end_date, :platform, CAST(:email_recipients AS json)
)
"""),
{
"id": meeting_id,
"room_name": "integration-test-room",
"room_url": "https://test.daily.co/integration-test-room",
"host_room_url": "https://test.daily.co/integration-test-room",
"start_date": now,
"end_date": now + timedelta(hours=1),
"platform": "daily",
"email_recipients": json.dumps([TEST_EMAIL]),
},
)
# Insert recording with track_keys, linked to meeting
await conn.execute(
text("""
INSERT INTO recording (id, bucket_name, object_key, recorded_at, status, track_keys, meeting_id)
VALUES (:id, :bucket_name, :object_key, :recorded_at, :status, CAST(:track_keys AS json), :meeting_id)
"""),
{
"id": recording_id,
"bucket_name": bucket_name,
"object_key": TRACK_KEYS[0],
"recorded_at": now,
"status": "completed",
"track_keys": json.dumps(TRACK_KEYS),
"meeting_id": meeting_id,
},
)
# Link recording to transcript and set status to uploaded
await conn.execute(
text("""
UPDATE transcript
SET recording_id = :recording_id, status = 'uploaded'
WHERE id = :transcript_id
"""),
{
"recording_id": recording_id,
"transcript_id": transcript_id,
},
)
# 4. Trigger processing via process endpoint
resp = await api_client.post(f"/transcripts/{transcript_id}/process")
assert resp.status_code == 200, f"Process trigger failed: {resp.text}"
# 5. Poll until pipeline completes
# The pipeline will call mock-daily for get_recording and get_participants
# Accept "error" too — non-critical steps like action_items may fail due to
# LLM parsing flakiness while core results (transcript, summaries) still exist.
data = await poll_transcript_status(
api_client, transcript_id, target=("ended", "error"), max_wait=300
)
# 6. Assertions — verify core pipeline results regardless of final status
assert data.get("title") and len(data["title"]) > 0, "Title should be non-empty"
assert (
data.get("long_summary") and len(data["long_summary"]) > 0
), "Long summary should be non-empty"
assert (
data.get("short_summary") and len(data["short_summary"]) > 0
), "Short summary should be non-empty"
# Topics are served from a separate endpoint
topics_resp = await api_client.get(f"/transcripts/{transcript_id}/topics")
assert topics_resp.status_code == 200, f"Failed to get topics: {topics_resp.text}"
topics = topics_resp.json()
assert len(topics) >= 1, "Should have at least 1 topic"
for topic in topics:
assert topic.get("title"), "Each topic should have a title"
assert topic.get("summary"), "Each topic should have a summary"
# Participants are served from a separate endpoint
participants_resp = await api_client.get(
f"/transcripts/{transcript_id}/participants"
)
assert (
participants_resp.status_code == 200
), f"Failed to get participants: {participants_resp.text}"
participants = participants_resp.json()
assert (
len(participants) >= 2
), f"Expected at least 2 speakers for multitrack, got {len(participants)}"
# 7. Verify email transcript notification
# The send_email pipeline task should have sent an email to TEST_EMAIL via Mailpit.
# Note: share_mode is only set to "public" when meeting has email_recipients;
# room-level emails do NOT change share_mode.
# Poll Mailpit for the delivered email (send_email task runs async after finalize)
messages = await poll_mailpit_messages(mailpit_client, TEST_EMAIL, max_wait=30)
assert len(messages) >= 1, "Should have received at least 1 email"
email_msg = messages[0]
assert (
"Transcript Ready" in email_msg.get("Subject", "")
), f"Email subject should contain 'Transcript Ready', got: {email_msg.get('Subject')}"

17
server/tests/test_app.py Normal file
View File

@@ -0,0 +1,17 @@
"""Tests for app-level endpoints (root, not under /v1)."""
import pytest
@pytest.mark.asyncio
async def test_health_endpoint_returns_healthy():
"""GET /health returns 200 and {"status": "healthy"} for probes and CI."""
from httpx import AsyncClient
from reflector.app import app
# Health is at app root, not under /v1
async with AsyncClient(app=app, base_url="http://test") as root_client:
response = await root_client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}

View File

@@ -76,8 +76,10 @@ async def test_cleanup_old_public_data_deletes_old_anonymous_transcripts():
assert result["transcripts_deleted"] == 1
assert result["errors"] == []
# Verify old anonymous transcript was deleted
assert await transcripts_controller.get_by_id(old_transcript.id) is None
# Verify old anonymous transcript was soft-deleted
old = await transcripts_controller.get_by_id(old_transcript.id)
assert old is not None
assert old.deleted_at is not None
# Verify new anonymous transcript still exists
assert await transcripts_controller.get_by_id(new_transcript.id) is not None
@@ -150,15 +152,17 @@ async def test_cleanup_deletes_associated_meeting_and_recording():
assert result["recordings_deleted"] == 1
assert result["errors"] == []
# Verify transcript was deleted
assert await transcripts_controller.get_by_id(old_transcript.id) is None
# Verify transcript was soft-deleted
old = await transcripts_controller.get_by_id(old_transcript.id)
assert old is not None
assert old.deleted_at is not None
# Verify meeting was deleted
# Verify meeting was hard-deleted (cleanup deletes meetings directly)
query = meetings.select().where(meetings.c.id == meeting_id)
meeting_result = await get_database().fetch_one(query)
assert meeting_result is None
# Verify recording was deleted
# Verify recording was hard-deleted (cleanup deletes recordings directly)
assert await recordings_controller.get_by_id(recording.id) is None

206
server/tests/test_email.py Normal file
View File

@@ -0,0 +1,206 @@
"""Tests for reflector.email — transcript email composition and sending."""
from unittest.mock import AsyncMock, patch
import pytest
from reflector.db.transcripts import (
SourceKind,
Transcript,
TranscriptParticipant,
TranscriptTopic,
)
from reflector.email import (
_build_html,
_build_plain_text,
get_transcript_url,
send_transcript_email,
)
from reflector.processors.types import Word
def _make_transcript(
*,
title: str | None = "Weekly Standup",
short_summary: str | None = "Team discussed sprint progress.",
with_topics: bool = True,
share_mode: str = "private",
source_kind: SourceKind = SourceKind.FILE,
) -> Transcript:
topics = []
participants = []
if with_topics:
participants = [
TranscriptParticipant(id="p1", speaker=0, name="Alice"),
TranscriptParticipant(id="p2", speaker=1, name="Bob"),
]
topics = [
TranscriptTopic(
title="Intro",
summary="Greetings",
timestamp=0.0,
duration=10.0,
words=[
Word(text="Hello", start=0.0, end=0.5, speaker=0),
Word(text="everyone", start=0.5, end=1.0, speaker=0),
Word(text="Thanks", start=5.0, end=5.5, speaker=1),
Word(text="for", start=5.5, end=5.8, speaker=1),
Word(text="joining", start=5.8, end=6.2, speaker=1),
],
),
]
return Transcript(
id="tx-123",
title=title,
short_summary=short_summary,
topics=topics,
participants=participants,
share_mode=share_mode,
source_kind=source_kind,
)
URL = "http://localhost:3000/transcripts/tx-123"
class TestBuildPlainText:
def test_full_content_with_link(self):
t = _make_transcript()
text = _build_plain_text(t, URL, include_link=True)
assert text.startswith("Reflector: Weekly Standup")
assert "Team discussed sprint progress." in text
assert "[00:00] Alice:" in text
assert "[00:05] Bob:" in text
assert URL in text
def test_full_content_without_link(self):
t = _make_transcript()
text = _build_plain_text(t, URL, include_link=False)
assert "Reflector: Weekly Standup" in text
assert "Team discussed sprint progress." in text
assert "[00:00] Alice:" in text
assert URL not in text
def test_no_summary(self):
t = _make_transcript(short_summary=None)
text = _build_plain_text(t, URL, include_link=True)
assert "Summary:" not in text
assert "[00:00] Alice:" in text
def test_no_topics(self):
t = _make_transcript(with_topics=False)
text = _build_plain_text(t, URL, include_link=True)
assert "Transcript:" not in text
assert "Reflector: Weekly Standup" in text
def test_unnamed_recording(self):
t = _make_transcript(title=None)
text = _build_plain_text(t, URL, include_link=True)
assert "Reflector: Unnamed recording" in text
class TestBuildHtml:
def test_full_content_with_link(self):
t = _make_transcript()
html = _build_html(t, URL, include_link=True)
assert "Weekly Standup" in html
assert "Team discussed sprint progress." in html
assert "Alice" in html
assert "Bob" in html
assert URL in html
assert "View Transcript" in html
def test_full_content_without_link(self):
t = _make_transcript()
html = _build_html(t, URL, include_link=False)
assert "Weekly Standup" in html
assert "Alice" in html
assert URL not in html
assert "View Transcript" not in html
def test_no_summary(self):
t = _make_transcript(short_summary=None)
html = _build_html(t, URL, include_link=True)
assert "sprint progress" not in html
assert "Alice" in html
def test_no_topics(self):
t = _make_transcript(with_topics=False)
html = _build_html(t, URL, include_link=True)
assert "Transcript" not in html or "View Transcript" in html
def test_html_escapes_title(self):
t = _make_transcript(title='<script>alert("xss")</script>')
html = _build_html(t, URL, include_link=True)
assert "<script>" not in html
assert "&lt;script&gt;" in html
class TestGetTranscriptUrl:
def test_url_format(self):
t = _make_transcript()
url = get_transcript_url(t)
assert url.endswith("/transcripts/tx-123")
class TestSendTranscriptEmail:
@pytest.mark.asyncio
async def test_include_link_default_true(self):
t = _make_transcript()
with (
patch("reflector.email.is_email_configured", return_value=True),
patch(
"reflector.email.aiosmtplib.send", new_callable=AsyncMock
) as mock_send,
):
count = await send_transcript_email(["a@test.com"], t)
assert count == 1
call_args = mock_send.call_args
msg = call_args[0][0]
assert msg["Subject"] == "Reflector: Weekly Standup"
# Default include_link=True, so HTML part should contain the URL
html_part = msg.get_payload()[1].get_payload()
assert "/transcripts/tx-123" in html_part
@pytest.mark.asyncio
async def test_include_link_false(self):
t = _make_transcript()
with (
patch("reflector.email.is_email_configured", return_value=True),
patch(
"reflector.email.aiosmtplib.send", new_callable=AsyncMock
) as mock_send,
):
count = await send_transcript_email(["a@test.com"], t, include_link=False)
assert count == 1
msg = mock_send.call_args[0][0]
html_part = msg.get_payload()[1].get_payload()
assert "/transcripts/tx-123" not in html_part
plain_part = msg.get_payload()[0].get_payload()
assert "/transcripts/tx-123" not in plain_part
@pytest.mark.asyncio
async def test_skips_when_not_configured(self):
t = _make_transcript()
with patch("reflector.email.is_email_configured", return_value=False):
count = await send_transcript_email(["a@test.com"], t)
assert count == 0
@pytest.mark.asyncio
async def test_skips_empty_recipients(self):
t = _make_transcript()
with patch("reflector.email.is_email_configured", return_value=True):
count = await send_transcript_email([], t)
assert count == 0

View File

@@ -0,0 +1,290 @@
"""
Tests for FailedRunsMonitor Hatchet cron workflow.
Tests cover:
- No Zulip message sent when no failures found
- Messages sent for failed main pipeline runs
- Child workflow failures filtered out
- Errors in the monitor itself are caught and logged
"""
from datetime import timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hatchet_sdk.clients.rest.models import V1TaskStatus
def _make_task_summary(
workflow_name: str,
workflow_run_external_id: str = "run-123",
status: V1TaskStatus = V1TaskStatus.FAILED,
):
"""Create a mock V1TaskSummary."""
mock = MagicMock()
mock.workflow_name = workflow_name
mock.workflow_run_external_id = workflow_run_external_id
mock.status = status
return mock
@pytest.mark.asyncio
class TestCheckFailedRuns:
async def test_no_failures_sends_no_message(self):
mock_result = MagicMock()
mock_result.rows = []
mock_client = MagicMock()
mock_client.runs.aio_list = AsyncMock(return_value=mock_result)
with (
patch(
"reflector.hatchet.workflows.failed_runs_monitor.HatchetClientManager.get_client",
return_value=mock_client,
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.send_message_to_zulip",
new_callable=AsyncMock,
) as mock_send,
):
from reflector.hatchet.workflows.failed_runs_monitor import (
_check_failed_runs,
)
result = await _check_failed_runs()
assert result["checked"] == 0
assert result["reported"] == 0
mock_send.assert_not_called()
async def test_reports_failed_main_pipeline_runs(self):
failed_runs = [
_make_task_summary("DiarizationPipeline", "run-1"),
_make_task_summary("FilePipeline", "run-2"),
]
mock_result = MagicMock()
mock_result.rows = failed_runs
mock_details = MagicMock()
mock_client = MagicMock()
mock_client.runs.aio_list = AsyncMock(return_value=mock_result)
mock_client.runs.aio_get = AsyncMock(return_value=mock_details)
with (
patch(
"reflector.hatchet.workflows.failed_runs_monitor.HatchetClientManager.get_client",
return_value=mock_client,
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.render_run_detail",
return_value="**rendered DAG**",
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.send_message_to_zulip",
new_callable=AsyncMock,
return_value={"id": 1},
) as mock_send,
patch(
"reflector.hatchet.workflows.failed_runs_monitor.settings"
) as mock_settings,
):
mock_settings.ZULIP_DAG_STREAM = "dag-stream"
mock_settings.ZULIP_DAG_TOPIC = "dag-topic"
from reflector.hatchet.workflows.failed_runs_monitor import (
_check_failed_runs,
)
result = await _check_failed_runs()
assert result["checked"] == 2
assert result["reported"] == 2
assert mock_send.call_count == 2
mock_send.assert_any_call("dag-stream", "dag-topic", "**rendered DAG**")
async def test_filters_out_child_workflows(self):
runs = [
_make_task_summary("DiarizationPipeline", "run-1"),
_make_task_summary("TrackProcessing", "run-2"),
_make_task_summary("TopicChunkProcessing", "run-3"),
_make_task_summary("SubjectProcessing", "run-4"),
]
mock_result = MagicMock()
mock_result.rows = runs
mock_details = MagicMock()
mock_client = MagicMock()
mock_client.runs.aio_list = AsyncMock(return_value=mock_result)
mock_client.runs.aio_get = AsyncMock(return_value=mock_details)
with (
patch(
"reflector.hatchet.workflows.failed_runs_monitor.HatchetClientManager.get_client",
return_value=mock_client,
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.render_run_detail",
return_value="**rendered**",
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.send_message_to_zulip",
new_callable=AsyncMock,
return_value={"id": 1},
) as mock_send,
patch(
"reflector.hatchet.workflows.failed_runs_monitor.settings"
) as mock_settings,
):
mock_settings.ZULIP_DAG_STREAM = "dag-stream"
mock_settings.ZULIP_DAG_TOPIC = "dag-topic"
from reflector.hatchet.workflows.failed_runs_monitor import (
_check_failed_runs,
)
result = await _check_failed_runs()
# Only DiarizationPipeline should be reported
assert result["checked"] == 4
assert result["reported"] == 1
assert mock_send.call_count == 1
async def test_all_three_pipelines_reported(self):
runs = [
_make_task_summary("DiarizationPipeline", "run-1"),
_make_task_summary("FilePipeline", "run-2"),
_make_task_summary("LivePostProcessingPipeline", "run-3"),
]
mock_result = MagicMock()
mock_result.rows = runs
mock_details = MagicMock()
mock_client = MagicMock()
mock_client.runs.aio_list = AsyncMock(return_value=mock_result)
mock_client.runs.aio_get = AsyncMock(return_value=mock_details)
with (
patch(
"reflector.hatchet.workflows.failed_runs_monitor.HatchetClientManager.get_client",
return_value=mock_client,
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.render_run_detail",
return_value="**rendered**",
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.send_message_to_zulip",
new_callable=AsyncMock,
return_value={"id": 1},
) as mock_send,
patch(
"reflector.hatchet.workflows.failed_runs_monitor.settings"
) as mock_settings,
):
mock_settings.ZULIP_DAG_STREAM = "dag-stream"
mock_settings.ZULIP_DAG_TOPIC = "dag-topic"
from reflector.hatchet.workflows.failed_runs_monitor import (
_check_failed_runs,
)
result = await _check_failed_runs()
assert result["reported"] == 3
assert mock_send.call_count == 3
async def test_continues_on_individual_run_failure(self):
"""If one run fails to report, the others should still be reported."""
runs = [
_make_task_summary("DiarizationPipeline", "run-1"),
_make_task_summary("FilePipeline", "run-2"),
]
mock_result = MagicMock()
mock_result.rows = runs
mock_client = MagicMock()
mock_client.runs.aio_list = AsyncMock(return_value=mock_result)
# First call raises, second succeeds
mock_client.runs.aio_get = AsyncMock(
side_effect=[Exception("Hatchet API error"), MagicMock()]
)
with (
patch(
"reflector.hatchet.workflows.failed_runs_monitor.HatchetClientManager.get_client",
return_value=mock_client,
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.render_run_detail",
return_value="**rendered**",
),
patch(
"reflector.hatchet.workflows.failed_runs_monitor.send_message_to_zulip",
new_callable=AsyncMock,
return_value={"id": 1},
) as mock_send,
patch(
"reflector.hatchet.workflows.failed_runs_monitor.settings"
) as mock_settings,
):
mock_settings.ZULIP_DAG_STREAM = "dag-stream"
mock_settings.ZULIP_DAG_TOPIC = "dag-topic"
from reflector.hatchet.workflows.failed_runs_monitor import (
_check_failed_runs,
)
result = await _check_failed_runs()
# First run failed to report, second succeeded
assert result["reported"] == 1
assert mock_send.call_count == 1
async def test_handles_list_api_failure(self):
"""If aio_list fails, should return error and not crash."""
mock_client = MagicMock()
mock_client.runs.aio_list = AsyncMock(
side_effect=Exception("Connection refused")
)
with patch(
"reflector.hatchet.workflows.failed_runs_monitor.HatchetClientManager.get_client",
return_value=mock_client,
):
from reflector.hatchet.workflows.failed_runs_monitor import (
_check_failed_runs,
)
result = await _check_failed_runs()
assert result["checked"] == 0
assert result["reported"] == 0
assert "error" in result
async def test_uses_correct_time_window(self):
"""Verify the correct since/until parameters are passed to aio_list."""
mock_result = MagicMock()
mock_result.rows = []
mock_client = MagicMock()
mock_client.runs.aio_list = AsyncMock(return_value=mock_result)
with patch(
"reflector.hatchet.workflows.failed_runs_monitor.HatchetClientManager.get_client",
return_value=mock_client,
):
from reflector.hatchet.workflows.failed_runs_monitor import (
_check_failed_runs,
)
await _check_failed_runs()
call_kwargs = mock_client.runs.aio_list.call_args
assert call_kwargs.kwargs["statuses"] == [V1TaskStatus.FAILED]
since = call_kwargs.kwargs["since"]
until = call_kwargs.kwargs["until"]
assert since.tzinfo == timezone.utc
assert until.tzinfo == timezone.utc
# Window should be ~1 hour
delta = until - since
assert 3590 < delta.total_seconds() < 3610

View File

@@ -37,18 +37,3 @@ async def test_hatchet_client_can_replay_handles_exception():
# Should return False on error (workflow might be gone)
assert can_replay is False
def test_hatchet_client_raises_without_token():
"""Test that get_client raises ValueError without token.
Useful: Catches if someone removes the token validation,
which would cause cryptic errors later.
"""
from reflector.hatchet.client import HatchetClientManager
with patch("reflector.hatchet.client.settings") as mock_settings:
mock_settings.HATCHET_CLIENT_TOKEN = None
with pytest.raises(ValueError, match="HATCHET_CLIENT_TOKEN must be set"):
HatchetClientManager.get_client()

Some files were not shown because too many files have changed in this diff Show More