Compare commits

..

36 Commits

Author SHA1 Message Date
Igor Loskutov
1bf73c8199 sync with parent 2025-10-21 11:59:26 -04:00
d82abf65ba Emit multriack pipeline events 2025-10-21 16:31:31 +02:00
Igor Loskutov
7d239fe380 dailico track merge vibe 2025-10-21 10:30:19 -04:00
acb6e90f28 Generate waveforms for the mixed audio 2025-10-21 13:33:31 +02:00
Igor Loskutov
f844b9fc1f Merge branch 'igor/dailico-2' of github-monadical:Monadical-SAS/reflector into igor/dailico-2 2025-10-17 10:00:40 -04:00
96f05020cc Align tracks of a multitrack recording 2025-10-17 15:27:27 +02:00
fc79ff3114 Use explicit track keys for processing 2025-10-17 14:42:07 +02:00
Igor Loskutov
3641e2e599 apply platform from envs in priority: non-dry 2025-10-16 15:08:19 -04:00
c23518d2e3 Trigger multitrack processing for daily recordings 2025-10-16 20:05:26 +02:00
23edffe2a2 Mixdown with pyav filter graph 2025-10-16 17:14:55 +02:00
e59770ecc9 Mixdown audio tracks 2025-10-16 17:14:55 +02:00
6301f2afa6 Add multitrack pipeline 2025-10-16 17:14:55 +02:00
9ac7f0e8e2 chore(main): release 0.14.0 (#670) 2025-10-16 17:14:55 +02:00
Igor Loskutov
0a84a9351a stub processor (vibe) self-review 2025-10-10 20:41:08 -04:00
Igor Loskutov
ca22084845 stub processor (vibe) self-review 2025-10-10 18:45:19 -04:00
Igor Loskutov
f945f84be9 stub processor (vibe) 2025-10-10 18:05:31 -04:00
Igor Loskutov
4c523c8eec dont show recording ui on call 2025-10-10 12:45:10 -04:00
Igor Loskutov
0fcf8b6875 doc update (vibe) 2025-10-10 10:57:35 -04:00
Igor Loskutov
446cb748ae vibe dailyco 2025-10-09 17:04:16 -04:00
Igor Loskutov
3e1339a8ea vibe dailyco 2025-10-09 15:52:23 -04:00
Igor Loskutov
807819bb2f llm instructions 2025-10-08 13:06:04 -04:00
5f6910e513 feat: Add calendar event data to transcript webhook payload (#689)
* feat: add calendar event data to transcript webhook payload and implement get_by_id method

* Update server/reflector/worker/webhook.py

Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>

* Update server/reflector/worker/webhook.py

Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>

* style: format conditional time fields with line breaks for better readability

* docs: add calendar event fields to transcript.completed webhook payload schema

---------

Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
2025-10-08 11:11:57 -05:00
9a71af145e fix: update transcript list on reprocess (#676)
* Update transcript list on reprocess

* Fix transcript create

* Fix multiple sockets issue

* Pass token in sec websocket protocol

* userEvent parse example

* transcript list invalidation non-abstraction

* Emit only relevant events to the user room

* Add ws close code const

* Refactor user websocket endpoint

* Refactor user events provider

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-07 19:11:30 +02:00
eef6dc3903 fix: upgrade nemo toolkit (#678) 2025-10-07 16:45:02 +02:00
Igor Monadical
1dee255fed parakeet endpoint doc (#679)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-07 10:41:01 -04:00
5d98754305 fix: security review (#656)
* Add security review doc

* Add tests to reproduce security issues

* Fix security issues

* Fix tests

* Set auth auth backend for tests

* Fix ics api tests

* Fix transcript mutate check

* Update frontent env var names

* Remove permissions doc
2025-09-29 23:07:49 +02:00
Igor Monadical
969bd84fcc feat: container build for www / github (#672)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-24 12:27:45 -04:00
Igor Monadical
36608849ec fix: restore feature boolean logic (#671)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-24 11:57:49 -04:00
Igor Monadical
5bf64b5a41 feat: docker-compose for production frontend (#664)
* docker-compose for production frontend

* fix: Remove external Redis port mapping for Coolify compatibility

Redis should only be accessible within the internal Docker network in Coolify deployments to avoid port conflicts with other applications.

* fix: Remove external port mapping for web service in Coolify

Coolify handles port exposure through its proxy (Traefik), so services should not expose ports directly in the docker-compose file.

* server side client envs

* missing vars

* nextjs experimental

* fix claude 'fix'

* remove build env vars compose

* docker

* remove ports for coolify

* review

* cleanup

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-24 11:15:27 -04:00
0aaa42528a chore(main): release 0.13.1 (#668) 2025-09-22 16:47:44 -06:00
565a62900f fix: TypeError on not all arguments converted during string formatting in logger (#667) 2025-09-22 16:45:28 -06:00
Igor Monadical
27016e6051 minimum release age for npm (#665)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-22 13:38:23 -04:00
6ddfee0b4e chore(main): release 0.13.0 (#661) 2025-09-21 20:50:47 -06:00
Igor Monadical
47716f6e5d feat: room form edit with enter (#662)
* room form edit with enter

* mobile form enter do nothing

* restore overwritten older change

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-19 15:14:40 -04:00
0abcebfc94 fix: invalid cleanup call (#660) 2025-09-18 10:02:30 -06:00
Igor Monadical
2b723da08b rooms-page-calendar-ics-room-name-fix (#659)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-17 20:02:17 -04:00
94 changed files with 11117 additions and 697 deletions

57
.github/workflows/docker-frontend.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Build and Push Frontend Docker Image
on:
push:
branches:
- main
paths:
- 'www/**'
- '.github/workflows/docker-frontend.yml'
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./www
file: ./www/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64

View File

@@ -1,5 +1,41 @@
# Changelog
## [0.14.0](https://github.com/Monadical-SAS/reflector/compare/v0.13.1...v0.14.0) (2025-10-08)
### Features
* Add calendar event data to transcript webhook payload ([#689](https://github.com/Monadical-SAS/reflector/issues/689)) ([5f6910e](https://github.com/Monadical-SAS/reflector/commit/5f6910e5131b7f28f86c9ecdcc57fed8412ee3cd))
* container build for www / github ([#672](https://github.com/Monadical-SAS/reflector/issues/672)) ([969bd84](https://github.com/Monadical-SAS/reflector/commit/969bd84fcc14851d1a101412a0ba115f1b7cde82))
* docker-compose for production frontend ([#664](https://github.com/Monadical-SAS/reflector/issues/664)) ([5bf64b5](https://github.com/Monadical-SAS/reflector/commit/5bf64b5a41f64535e22849b4bb11734d4dbb4aae))
### Bug Fixes
* restore feature boolean logic ([#671](https://github.com/Monadical-SAS/reflector/issues/671)) ([3660884](https://github.com/Monadical-SAS/reflector/commit/36608849ec64e953e3be456172502762e3c33df9))
* security review ([#656](https://github.com/Monadical-SAS/reflector/issues/656)) ([5d98754](https://github.com/Monadical-SAS/reflector/commit/5d98754305c6c540dd194dda268544f6d88bfaf8))
* update transcript list on reprocess ([#676](https://github.com/Monadical-SAS/reflector/issues/676)) ([9a71af1](https://github.com/Monadical-SAS/reflector/commit/9a71af145ee9b833078c78d0c684590ab12e9f0e))
* upgrade nemo toolkit ([#678](https://github.com/Monadical-SAS/reflector/issues/678)) ([eef6dc3](https://github.com/Monadical-SAS/reflector/commit/eef6dc39037329b65804297786d852dddb0557f9))
## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22)
### Bug Fixes
* TypeError on not all arguments converted during string formatting in logger ([#667](https://github.com/Monadical-SAS/reflector/issues/667)) ([565a629](https://github.com/Monadical-SAS/reflector/commit/565a62900f5a02fc946b68f9269a42190ed70ab6))
## [0.13.0](https://github.com/Monadical-SAS/reflector/compare/v0.12.1...v0.13.0) (2025-09-19)
### Features
* room form edit with enter ([#662](https://github.com/Monadical-SAS/reflector/issues/662)) ([47716f6](https://github.com/Monadical-SAS/reflector/commit/47716f6e5ddee952609d2fa0ffabdfa865286796))
### Bug Fixes
* invalid cleanup call ([#660](https://github.com/Monadical-SAS/reflector/issues/660)) ([0abcebf](https://github.com/Monadical-SAS/reflector/commit/0abcebfc9491f87f605f21faa3e53996fafedd9a))
## [0.12.1](https://github.com/Monadical-SAS/reflector/compare/v0.12.0...v0.12.1) (2025-09-17)

View File

@@ -151,7 +151,7 @@ All endpoints prefixed `/v1/`:
**Frontend** (`www/.env`):
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration
- `NEXT_PUBLIC_REFLECTOR_API_URL` - Backend API endpoint
- `REFLECTOR_API_URL` - Backend API endpoint
- `REFLECTOR_DOMAIN_CONFIG` - Feature flags and domain settings
## Testing Strategy

345
CODER_BRIEFING.md Normal file
View File

@@ -0,0 +1,345 @@
# Multi-Provider Video Platform Implementation - Coder Briefing
## Your Mission
Implement multi-provider video platform support in Reflector, allowing the system to work with both Whereby and Daily.co video conferencing providers. The goal is to abstract the current Whereby-only implementation and add Daily.co as a second provider, with the ability to switch between them via environment variables.
**Branch:** `igor/dailico-2` (you're already on it)
**Estimated Time:** 12-16 hours (senior engineer)
**Complexity:** Medium-High (requires careful integration with existing codebase)
---
## What You Have
### 1. **PLAN.md** - Your Technical Specification (2,452 lines)
- Complete step-by-step implementation guide
- All code examples you need
- Architecture diagrams and design rationale
- Testing strategy and success metrics
- **Read this first** to understand the overall approach
### 2. **IMPLEMENTATION_GUIDE.md** - Your Practical Guide
- What to copy vs. adapt vs. rewrite
- Common pitfalls and how to avoid them
- Verification checklists for each phase
- Decision trees for implementation choices
- **Use this as your day-to-day reference**
### 3. **Reference Implementation** - `./reflector-dailyco-reference/`
- Working implementation from 2.5 months ago
- Good architecture and patterns
- **BUT:** 91 commits behind current main, DO NOT merge directly
- Use for inspiration and code patterns only
---
## Critical Context: Why Not Just Merge?
The reference branch (`origin/igor/feat-dailyco`) was started on August 1, 2025 and is now severely diverged from main:
- **91 commits behind main**
- Main has 12x more changes (45,840 insertions vs 3,689)
- Main added: calendar integration, webhooks, full-text search, React Query migration, security fixes
- Reference removed: features that main still has and needs
**Merging would be a disaster.** We're implementing fresh on current main, using the reference for validated patterns.
---
## High-Level Approach
### Phase 1: Analysis (2 hours)
- Study current Whereby integration
- Define abstraction requirements
- Create standard data models
### Phase 2: Abstraction Layer (4-5 hours)
- Build platform abstraction (base class, registry, factory)
- Extract Whereby into the abstraction
- Update database schema (add `platform` field)
- Integrate into rooms.py **without breaking calendar/webhooks**
### Phase 3: Daily.co Implementation (4-5 hours)
- Implement Daily.co client
- Add webhook handler
- Create frontend components (rewrite API calls for React Query)
- Add recording processing
### Phase 4: Testing (2-3 hours)
- Unit tests for platform abstraction
- Integration tests for webhooks
- Manual testing with both providers
---
## Key Files You'll Touch
### Backend (New)
```
server/reflector/video_platforms/
├── __init__.py
├── base.py ← Abstract base class
├── models.py ← Platform, MeetingData, VideoPlatformConfig
├── registry.py ← Platform registration system
├── factory.py ← Client creation and config
├── whereby.py ← Whereby client wrapper
├── daily.py ← Daily.co client
└── mock.py ← Mock client for testing
server/reflector/views/daily.py ← Daily.co webhooks
server/tests/test_video_platforms.py ← Platform tests
server/tests/test_daily_webhook.py ← Webhook tests
```
### Backend (Modified - Careful!)
```
server/reflector/settings.py ← Add Daily.co settings
server/reflector/db/rooms.py ← Add platform field, PRESERVE calendar fields
server/reflector/db/meetings.py ← Add platform field
server/reflector/views/rooms.py ← Integrate abstraction, PRESERVE calendar/webhooks
server/reflector/worker/process.py ← Add process_recording_from_url task
server/reflector/app.py ← Register daily router
server/env.example ← Document new env vars
```
### Frontend (New)
```
www/app/[roomName]/components/
├── RoomContainer.tsx ← Platform router
├── DailyRoom.tsx ← Daily.co component (rewrite API calls!)
└── WherebyRoom.tsx ← Extract existing logic
```
### Frontend (Modified)
```
www/app/[roomName]/page.tsx ← Use RoomContainer
www/package.json ← Add @daily-co/daily-js
```
### Database
```
server/migrations/versions/XXXXXX_add_platform_support.py ← Generate fresh migration
```
---
## Critical Warnings ⚠️
### 1. **DO NOT Copy Database Migrations**
The reference migration has the wrong `down_revision` and is based on old schema.
```bash
# Instead:
cd server
uv run alembic revision -m "add_platform_support"
# Then edit the generated file
```
### 2. **DO NOT Remove Main's Features**
Main has calendar integration, webhooks, ICS sync that reference doesn't have.
When modifying `rooms.py`, only change meeting creation logic, preserve everything else.
### 3. **DO NOT Copy Frontend API Calls**
Reference uses old OpenAPI client. Main uses React Query.
Check how main currently makes API calls and replicate that pattern.
### 4. **DO NOT Copy package.json/migrations**
These files are severely outdated in reference.
### 5. **Preserve Type Safety**
Use `TYPE_CHECKING` imports to avoid circular dependencies:
```python
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from reflector.db.rooms import Room
```
---
## How to Start
### Day 1 Morning: Setup & Understanding (2-3 hours)
```bash
# 1. Verify you're on the right branch
git branch
# Should show: igor/dailico-2
# 2. Read the docs (in order)
# - PLAN.md (skim to understand scope, read Phase 1 carefully)
# - IMPLEMENTATION_GUIDE.md (read fully, bookmark it)
# 3. Study current Whereby integration
cat server/reflector/views/rooms.py | grep -A 20 "whereby"
cat www/app/[roomName]/page.tsx
# 4. Check reference implementation structure
ls -la reflector-dailyco-reference/server/reflector/video_platforms/
```
### Day 1 Afternoon: Phase 1 Execution (2-3 hours)
```bash
# 5. Copy video_platforms directory from reference
cp -r reflector-dailyco-reference/server/reflector/video_platforms/ \
server/reflector/
# 6. Review and fix imports
cd server
uv run ruff check reflector/video_platforms/
# 7. Add settings to settings.py (see PLAN.md Phase 2.7)
# 8. Test imports work
uv run python -c "from reflector.video_platforms import create_platform_client; print('OK')"
```
### Day 2: Phase 2 - Database & Integration (4-5 hours)
```bash
# 9. Generate migration
uv run alembic revision -m "add_platform_support"
# Edit the file following PLAN.md Phase 2.8
# 10. Update Room/Meeting models
# Add platform field, PRESERVE all existing fields
# 11. Integrate into rooms.py
# Carefully modify meeting creation, preserve calendar/webhooks
# 12. Add Daily.co webhook handler
cp reflector-dailyco-reference/server/reflector/views/daily.py \
server/reflector/views/
# Register in app.py
```
### Day 3: Phase 3 - Frontend & Testing (4-5 hours)
```bash
# 13. Create frontend components
mkdir -p www/app/[roomName]/components
# 14. Add Daily.co dependency
cd www
pnpm add @daily-co/daily-js@^0.81.0
# 15. Create RoomContainer, DailyRoom, WherebyRoom
# IMPORTANT: Rewrite API calls using React Query patterns
# 16. Regenerate types
pnpm openapi
# 17. Copy and adapt tests
cp reflector-dailyco-reference/server/tests/test_*.py server/tests/
# 18. Run tests
cd server
REDIS_HOST=localhost \
CELERY_BROKER_URL=redis://localhost:6379/1 \
uv run pytest tests/test_video_platforms.py -v
```
---
## Verification Checklist
After implementation, all of these must pass:
**Backend:**
- [ ] `cd server && uv run ruff check .` passes
- [ ] `uv run alembic upgrade head` works cleanly
- [ ] `uv run pytest tests/test_video_platforms.py` passes
- [ ] Can import: `from reflector.video_platforms import create_platform_client`
- [ ] Settings has all Daily.co variables
**Frontend:**
- [ ] `cd www && pnpm lint` passes
- [ ] No TypeScript errors
- [ ] `pnpm openapi` generates platform field
- [ ] No `@ts-ignore` for platform field
**Integration:**
- [ ] Whereby meetings still work (existing flow unchanged)
- [ ] Calendar/webhook features still work in rooms.py
- [ ] env.example documents all new variables
---
## When You're Stuck
### Check These Resources:
1. **PLAN.md** - Detailed code examples for your exact scenario
2. **IMPLEMENTATION_GUIDE.md** - Common pitfalls section
3. **Reference code** - See how it was solved before
4. **Git diff** - Compare reference to your implementation
### Compare Files:
```bash
# See what reference did
diff reflector-dailyco-reference/server/reflector/views/rooms.py \
server/reflector/views/rooms.py
# See what changed in main since reference branch
git log --oneline --since="2025-08-01" -- server/reflector/views/rooms.py
```
### Common Issues:
- **Circular imports:** Use `TYPE_CHECKING` pattern
- **Tests fail with postgres error:** Use `REDIS_HOST=localhost` env vars
- **Frontend API calls broken:** Check current React Query patterns in main
- **Migrations fail:** Ensure you generated fresh, not copied
---
## Success Looks Like
When you're done:
- ✅ All tests pass
- ✅ Linting passes
- ✅ Can create Whereby meetings (unchanged behavior)
- ✅ Can create Daily.co meetings (with env vars)
- ✅ Calendar/webhooks still work
- ✅ Frontend has no TypeScript errors
- ✅ Platform selection via environment variables works
---
## Communication
If you need clarification on requirements, have questions about architecture decisions, or find issues with the spec, document them clearly with:
- What you expected
- What you found
- Your proposed solution
The PLAN.md document is comprehensive but you may find edge cases. Use your engineering judgment and document decisions.
---
## Final Notes
**This is not a simple copy-paste job.** You're doing careful integration work where you need to:
- Understand the abstraction pattern (PLAN.md)
- Preserve all of main's features
- Adapt reference code to current patterns
- Think about edge cases and testing
Take your time with Phase 2 (rooms.py integration) - that's where most bugs will come from if you accidentally break calendar/webhook features.
**Good luck! You've got comprehensive specs, working reference code, and a clean starting point. You can do this.**
---
## Quick Reference
```bash
# Your workspace
├── PLAN.md ← Complete technical spec (read first)
├── IMPLEMENTATION_GUIDE.md ← Practical guide (bookmark this)
├── CODER_BRIEFING.md ← This file
└── reflector-dailyco-reference/ ← Reference implementation (inspiration only)
# Key commands
cd server && uv run ruff check . # Lint backend
cd www && pnpm lint # Lint frontend
cd server && uv run alembic revision -m "..." # Create migration
cd www && pnpm openapi # Regenerate types
cd server && uv run pytest -v # Run tests
```

489
IMPLEMENTATION_GUIDE.md Normal file
View File

@@ -0,0 +1,489 @@
# Daily.co Implementation Guide
## Overview
Implement multi-provider video platform support (Whereby + Daily.co) following PLAN.md.
## Reference Code Location
- **Reference branch:** `origin/igor/feat-dailyco` (on remote)
- **Worktree location:** `./reflector-dailyco-reference/`
- **Status:** Reference only - DO NOT merge or copy directly
## What Exists in Reference Branch (For Inspiration)
### ✅ Can Use As Reference (Well-Implemented)
```
server/reflector/video_platforms/
├── base.py ← Platform abstraction (good design, copy-safe)
├── models.py ← Data models (copy-safe)
├── registry.py ← Registry pattern (copy-safe)
├── factory.py ← Factory pattern (needs settings updates)
├── whereby.py ← Whereby client (needs adaptation)
├── daily.py ← Daily.co client (needs adaptation)
└── mock.py ← Mock client (copy-safe for tests)
server/reflector/views/daily.py ← Webhook handler (needs adaptation)
server/tests/test_video_platforms.py ← Tests (good reference)
server/tests/test_daily_webhook.py ← Tests (good reference)
www/app/[roomName]/components/
├── RoomContainer.tsx ← Platform router (needs React Query)
├── DailyRoom.tsx ← Daily component (needs React Query)
└── WherebyRoom.tsx ← Whereby extraction (needs React Query)
```
### ⚠️ Needs Significant Changes (Use Logic Only)
- `server/reflector/db/rooms.py` - Reference removed calendar/webhook fields that main has
- `server/reflector/db/meetings.py` - Same issue (missing user_id handling differences)
- `server/reflector/views/rooms.py` - Main has calendar integration, webhooks, ICS sync
- `server/reflector/worker/process.py` - Main has different recording flow
- Migration files - Must regenerate against current main schema
### ❌ Do NOT Use (Outdated/Incompatible)
- `package.json`/`pnpm-lock.yaml` - Main uses different dependency versions
- Frontend API client calls - Main uses React Query (reference uses old OpenAPI client)
- Database migrations - Must create new ones from scratch
- Any files that delete features present in main (search, calendar, webhooks)
## Key Differences: Reference vs Current Main
| Aspect | Reference Branch | Current Main | Action Required |
|--------|------------------|--------------|-----------------|
| **API client** | Old OpenAPI generated | React Query hooks | Rewrite all API calls |
| **Database schema** | Simplified (removed features) | Has calendar, webhooks, full-text search | Merge carefully, preserve main features |
| **Settings** | Aug 2025 structure | Current structure | Adapt carefully |
| **Migrations** | Branched from Aug 1 | Current main (91+ commits ahead) | Regenerate from scratch |
| **Frontend deps** | `@daily-co/daily-js@0.81.0` | Check current versions | Update to compatible versions |
| **Package manager** | yarn | pnpm (maybe both?) | Use what main uses |
## Branch Divergence Analysis
**The reference branch is 91 commits behind main and severely diverged:**
- Reference: 8 commits, 3,689 insertions, 425 deletions
- Main since divergence: 320 files changed, 45,840 insertions, 16,827 deletions
- **Main has 12x more changes**
**Major features in main that reference lacks:**
1. Calendar integration (ICS sync with rooms)
2. Self-hosted GPU API infrastructure
3. Frontend OpenAPI React Query migration
4. Full-text search (backend + frontend)
5. Webhook system for room events
6. Environment variable migration
7. Security fixes and auth improvements
8. Docker production frontend
9. Meeting user ID removal (schema change)
10. NextJS version upgrades
**High conflict risk files:**
- `server/reflector/views/rooms.py` - 12x more changes in main
- `server/reflector/db/rooms.py` - Main added 7+ fields
- `www/package.json` - NextJS major version bump
- Database migrations - 20+ new migrations in main
## Implementation Approach
### Phase 1: Copy Clean Abstractions (1-2 hours)
**Files to copy directly from reference:**
```bash
# Core abstraction (review but mostly safe to copy)
cp -r reflector-dailyco-reference/server/reflector/video_platforms/ \
server/reflector/
# BUT review each file for:
# - Import paths (make sure they match current main)
# - Settings references (adapt to current settings.py)
# - Type imports (ensure no circular dependencies)
```
**After copying, immediately:**
```bash
cd server
# Check for issues
uv run ruff check reflector/video_platforms/
# Fix any import errors or type issues
```
### Phase 2: Adapt to Current Main (2-3 hours)
**2.1 Settings Integration**
File: `server/reflector/settings.py`
Add at the appropriate location (near existing Whereby settings):
```python
# Daily.co API Integration (NEW)
DAILY_API_KEY: str | None = None
DAILY_WEBHOOK_SECRET: str | None = None
DAILY_SUBDOMAIN: str | None = None
AWS_DAILY_S3_BUCKET: str | None = None
AWS_DAILY_S3_REGION: str = "us-west-2"
AWS_DAILY_ROLE_ARN: str | None = None
# Platform Migration Feature Flags (NEW)
DAILY_MIGRATION_ENABLED: bool = False # Conservative default
DAILY_MIGRATION_ROOM_IDS: list[str] = []
DEFAULT_VIDEO_PLATFORM: Literal["whereby", "daily"] = "whereby"
```
**2.2 Database Migration**
⚠️ **CRITICAL: Do NOT copy migration from reference**
Generate new migration:
```bash
cd server
uv run alembic revision -m "add_platform_support"
```
Edit the generated migration file to add `platform` column:
```python
def upgrade():
with op.batch_alter_table("room", schema=None) as batch_op:
batch_op.add_column(
sa.Column("platform", sa.String(), nullable=False, server_default="whereby")
)
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.add_column(
sa.Column("platform", sa.String(), nullable=False, server_default="whereby")
)
```
**2.3 Update Database Models**
File: `server/reflector/db/rooms.py`
Add platform field (preserve all existing fields from main):
```python
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from reflector.video_platforms.models import Platform
class Room:
# ... ALL existing fields from main (calendar, webhooks, etc.) ...
# NEW: Platform field
platform: "Platform" = sqlalchemy.Column(
sqlalchemy.String,
nullable=False,
server_default="whereby",
)
```
File: `server/reflector/db/meetings.py`
Same approach - add platform field, preserve everything from main.
**2.4 Integrate Platform Abstraction into rooms.py**
⚠️ **This is the most delicate part - main has calendar/webhook features**
File: `server/reflector/views/rooms.py`
Strategy:
1. Add imports at top
2. Modify meeting creation logic only
3. Preserve all calendar/webhook/ICS logic from main
```python
# Add imports
from reflector.video_platforms import (
create_platform_client,
get_platform_for_room,
)
# In create_meeting endpoint:
# OLD: Direct Whereby API calls
# NEW: Platform abstraction
# Find the meeting creation section and replace:
platform = get_platform_for_room(room.id)
client = create_platform_client(platform)
meeting_data = await client.create_meeting(
room_name_prefix=room.name,
end_date=meeting_data.end_date,
room=room,
)
# Then create Meeting record with meeting_data.platform, meeting_data.meeting_id, etc.
```
**2.5 Add Daily.co Webhook Handler**
Copy from reference, minimal changes needed:
```bash
cp reflector-dailyco-reference/server/reflector/views/daily.py \
server/reflector/views/
```
Register in `server/reflector/app.py`:
```python
from reflector.views import daily
app.include_router(daily.router, prefix="/v1/daily", tags=["daily"])
```
**2.6 Add Recording Processing Task**
File: `server/reflector/worker/process.py`
Add the `process_recording_from_url` task from reference (copy the function).
### Phase 3: Frontend Adaptation (3-4 hours)
**3.1 Determine Current API Client Pattern**
First, check how main currently makes API calls:
```bash
cd www
grep -r "api\." app/ | head -20
# Look for patterns like: api.v1Something()
```
**3.2 Create Components**
Copy component structure from reference but **rewrite all API calls**:
```bash
mkdir -p www/app/[roomName]/components
```
Files to create:
- `RoomContainer.tsx` - Platform router (mostly copy-safe, just fix imports)
- `DailyRoom.tsx` - Needs React Query API calls
- `WherebyRoom.tsx` - Extract current room page logic
**Example React Query pattern** (adapt to your actual API):
```typescript
import { api } from '@/app/api/client'
// In DailyRoom.tsx
const handleConsent = async () => {
try {
await api.v1MeetingAudioConsent({
path: { meeting_id: meeting.id },
body: { consent: true },
})
// ...
} catch (error) {
// ...
}
}
```
**3.3 Add Daily.co Dependency**
Check current package manager:
```bash
cd www
ls package-lock.json yarn.lock pnpm-lock.yaml
```
Then install:
```bash
# If using pnpm
pnpm add @daily-co/daily-js@^0.81.0
# If using yarn
yarn add @daily-co/daily-js@^0.81.0
```
**3.4 Update TypeScript Types**
After backend changes, regenerate types:
```bash
cd www
pnpm openapi # or yarn openapi
```
This should pick up the new `platform` field on Meeting type.
### Phase 4: Testing (2-3 hours)
**4.1 Copy Test Structure**
```bash
cp reflector-dailyco-reference/server/tests/test_video_platforms.py \
server/tests/
cp reflector-dailyco-reference/server/tests/test_daily_webhook.py \
server/tests/
```
**4.2 Fix Test Imports and Fixtures**
Update imports to match current test infrastructure:
- Check `server/tests/conftest.py` for fixture patterns
- Update database access patterns if changed
- Fix any import errors
**4.3 Run Tests**
```bash
cd server
# Run with environment variables for Mac
REDIS_HOST=localhost \
CELERY_BROKER_URL=redis://localhost:6379/1 \
CELERY_RESULT_BACKEND=redis://localhost:6379/1 \
uv run pytest tests/test_video_platforms.py -v
```
### Phase 5: Environment Configuration
**Update `server/env.example`:**
Add at the end:
```bash
# Daily.co API Integration
DAILY_API_KEY=your-daily-api-key
DAILY_WEBHOOK_SECRET=your-daily-webhook-secret
DAILY_SUBDOMAIN=your-subdomain
AWS_DAILY_S3_BUCKET=your-daily-bucket
AWS_DAILY_S3_REGION=us-west-2
AWS_DAILY_ROLE_ARN=arn:aws:iam::ACCOUNT:role/DailyRecording
# Platform Selection
DAILY_MIGRATION_ENABLED=false # Master switch
DAILY_MIGRATION_ROOM_IDS=[] # Specific room IDs
DEFAULT_VIDEO_PLATFORM=whereby # Default platform
```
## Decision Tree: Copy vs Adapt vs Rewrite
```
┌─ Is it pure abstraction logic? (base.py, registry.py, models.py)
│ YES → Copy directly, review imports
│ NO → Continue ↓
├─ Does it touch database models?
│ YES → Adapt carefully, preserve main's fields
│ NO → Continue ↓
├─ Does it make API calls on frontend?
│ YES → Rewrite using React Query
│ NO → Continue ↓
├─ Is it a database migration?
│ YES → Generate fresh from current schema
│ NO → Continue ↓
└─ Does it touch rooms.py or core business logic?
YES → Merge carefully, preserve calendar/webhooks
NO → Safe to adapt from reference
```
## Verification Checklist
After each phase, verify:
**Phase 1 (Abstraction Layer):**
- [ ] `uv run ruff check server/reflector/video_platforms/` passes
- [ ] No circular import errors
- [ ] Can import `from reflector.video_platforms import create_platform_client`
**Phase 2 (Backend Integration):**
- [ ] `uv run ruff check server/` passes
- [ ] Migration file generated (not copied)
- [ ] Room and Meeting models have platform field
- [ ] rooms.py still has calendar/webhook features
**Phase 3 (Frontend):**
- [ ] `pnpm lint` passes
- [ ] No TypeScript errors
- [ ] No `@ts-ignore` for platform field
- [ ] API calls use React Query patterns
**Phase 4 (Testing):**
- [ ] Tests can be collected: `pytest tests/test_video_platforms.py --collect-only`
- [ ] Database fixtures work
- [ ] Mock platform works
**Phase 5 (Config):**
- [ ] env.example has Daily.co variables
- [ ] settings.py has all new variables
- [ ] No duplicate variable definitions
## Common Pitfalls
### 1. Database Schema Conflicts
**Problem:** Reference removed fields that main has (calendar, webhooks)
**Solution:** Always preserve main's fields, only add platform field
### 2. Migration Conflicts
**Problem:** Reference migration has wrong `down_revision`
**Solution:** Always generate fresh migration from current main
### 3. Frontend API Calls
**Problem:** Reference uses old API client patterns
**Solution:** Check current main's API usage, replicate that pattern
### 4. Import Errors
**Problem:** Circular imports with TYPE_CHECKING
**Solution:** Use `if TYPE_CHECKING:` for Room/Meeting imports in video_platforms
### 5. Test Database Issues
**Problem:** Tests fail with "could not translate host name 'postgres'"
**Solution:** Use environment variables: `REDIS_HOST=localhost DATABASE_URL=...`
### 6. Preserved Features Broken
**Problem:** Calendar/webhook features stop working
**Solution:** Carefully review rooms.py diff, only change meeting creation, not calendar logic
## File Modification Summary
**New files (can copy):**
- `server/reflector/video_platforms/*.py` (entire directory)
- `server/reflector/views/daily.py`
- `server/tests/test_video_platforms.py`
- `server/tests/test_daily_webhook.py`
- `www/app/[roomName]/components/RoomContainer.tsx`
- `www/app/[roomName]/components/DailyRoom.tsx`
- `www/app/[roomName]/components/WherebyRoom.tsx`
**Modified files (careful merging):**
- `server/reflector/settings.py` - Add Daily.co settings
- `server/reflector/db/rooms.py` - Add platform field
- `server/reflector/db/meetings.py` - Add platform field
- `server/reflector/views/rooms.py` - Integrate platform abstraction
- `server/reflector/worker/process.py` - Add process_recording_from_url
- `server/reflector/app.py` - Register daily router
- `server/env.example` - Add Daily.co variables
- `www/app/[roomName]/page.tsx` - Use RoomContainer
- `www/package.json` - Add @daily-co/daily-js
**Generated files (do not copy):**
- `server/migrations/versions/XXXXXX_add_platform_support.py` - Generate fresh
## Success Metrics
Implementation is complete when:
- [ ] All tests pass (including new platform tests)
- [ ] Linting passes (ruff, pnpm lint)
- [ ] Migration applies cleanly: `uv run alembic upgrade head`
- [ ] Can create Whereby meeting (existing flow unchanged)
- [ ] Can create Daily.co meeting (with env vars set)
- [ ] Frontend loads without TypeScript errors
- [ ] No features from main were accidentally removed
## Getting Help
**Reference documentation locations:**
- Implementation plan: `PLAN.md`
- Reference implementation: `./reflector-dailyco-reference/`
- Current main codebase: `./ ` (current directory)
**Compare implementations:**
```bash
# Compare specific files
diff reflector-dailyco-reference/server/reflector/video_platforms/base.py \
server/reflector/video_platforms/base.py
# See what changed in rooms.py between reference branch point and now
git log --oneline --since="2025-08-01" -- server/reflector/views/rooms.py
```
**Key insight:** The reference branch validates the approach and provides working code patterns, but you're implementing fresh against current main to avoid merge conflicts and preserve all new features.

2517
PLAN.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -168,6 +168,13 @@ You can manually process an audio file by calling the process tool:
uv run python -m reflector.tools.process path/to/audio.wav
```
## Build-time env variables
Next.js projects are more used to NEXT_PUBLIC_ prefixed buildtime vars. We don't have those for the reason we need to serve a ccustomizable prebuild docker container.
Instead, all the variables are runtime. Variables needed to the frontend are served to the frontend app at initial render.
It also means there's no static prebuild and no static files to serve for js/html.
## Feature Flags
@@ -177,24 +184,24 @@ Reflector uses environment variable-based feature flags to control application f
| Feature Flag | Environment Variable |
|-------------|---------------------|
| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` |
| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` |
| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` |
| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` |
| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` |
| `requireLogin` | `FEATURE_REQUIRE_LOGIN` |
| `privacy` | `FEATURE_PRIVACY` |
| `browse` | `FEATURE_BROWSE` |
| `sendToZulip` | `FEATURE_SEND_TO_ZULIP` |
| `rooms` | `FEATURE_ROOMS` |
### Setting Feature Flags
Feature flags are controlled via environment variables using the pattern `NEXT_PUBLIC_FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
Feature flags are controlled via environment variables using the pattern `FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
**Examples:**
```bash
# Enable user authentication requirement
NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
FEATURE_REQUIRE_LOGIN=true
# Disable browse functionality
NEXT_PUBLIC_FEATURE_BROWSE=false
FEATURE_BROWSE=false
# Enable Zulip integration
NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
FEATURE_SEND_TO_ZULIP=true
```

39
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,39 @@
# Production Docker Compose configuration for Frontend
# Usage: docker compose -f docker-compose.prod.yml up -d
services:
web:
build:
context: ./www
dockerfile: Dockerfile
image: reflector-frontend:latest
environment:
- KV_URL=${KV_URL:-redis://redis:6379}
- SITE_URL=${SITE_URL}
- API_URL=${API_URL}
- WEBSOCKET_URL=${WEBSOCKET_URL}
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-changeme-in-production}
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
- AUTHENTIK_REFRESH_TOKEN_URL=${AUTHENTIK_REFRESH_TOKEN_URL}
- SENTRY_DSN=${SENTRY_DSN}
- SENTRY_IGNORE_API_RESOLUTION_ERROR=${SENTRY_IGNORE_API_RESOLUTION_ERROR:-1}
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7.2-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 3s
retries: 3
volumes:
- redis_data:/data
volumes:
redis_data:

View File

@@ -39,7 +39,7 @@ services:
ports:
- 6379:6379
web:
image: node:18
image: node:22-alpine
ports:
- "3000:3000"
command: sh -c "corepack enable && pnpm install && pnpm dev"
@@ -50,6 +50,8 @@ services:
- /app/node_modules
env_file:
- ./www/.env.local
environment:
- NODE_ENV=development
postgres:
image: postgres:17

View File

@@ -77,7 +77,7 @@ image = (
.pip_install(
"hf_transfer==0.1.9",
"huggingface_hub[hf-xet]==0.31.2",
"nemo_toolkit[asr]==2.3.0",
"nemo_toolkit[asr]==2.5.0",
"cuda-python==12.8.0",
"fastapi==0.115.12",
"numpy<2",

613
server/DAILYCO_TEST.md Normal file
View File

@@ -0,0 +1,613 @@
# Daily.co Integration Test Plan
## ✅ IMPLEMENTATION STATUS: Real Transcription Active
**This test validates Daily.co multitrack recording integration with REAL transcription/diarization.**
The implementation includes complete audio processing pipeline:
- **Multitrack recordings** from Daily.co S3 (separate audio stream per participant)
- **PyAV-based audio mixdown** with PTS-based track alignment
- **Real transcription** via Modal GPU backend (Whisper)
- **Real diarization** via Modal GPU backend (speaker identification)
- **Per-track transcription** with timestamp synchronization
- **Complete database entities** (recording, transcript, topics, participants, words)
**Processing pipeline** (`PipelineMainMultitrack`):
1. Download all audio tracks from Daily.co S3
2. Align tracks by PTS (presentation timestamp) to handle late joiners
3. Mix tracks into single audio file for unified playback
4. Transcribe each track individually with proper offset handling
5. Perform diarization on mixed audio
6. Generate topics, summaries, and word-level timestamps
7. Convert audio to MP3 and generate waveform visualization
**Note:** A stub processor (`process_daily_recording`) exists for testing webhook flow without GPU costs, but the production code path uses `process_multitrack_recording` with full ML pipeline.
---
## Prerequisites
**1. Environment Variables** (check in `.env.development.local`):
```bash
# Daily.co API Configuration
DAILY_API_KEY=<key>
DAILY_SUBDOMAIN=monadical
DAILY_WEBHOOK_SECRET=<base64-encoded-secret>
AWS_DAILY_S3_BUCKET=reflector-dailyco-local
AWS_DAILY_S3_REGION=us-east-1
AWS_DAILY_ROLE_ARN=arn:aws:iam::950402358378:role/DailyCo
DAILY_MIGRATION_ENABLED=true
DAILY_MIGRATION_ROOM_IDS=["552640fd-16f2-4162-9526-8cf40cd2357e"]
# Transcription/Diarization Backend (Required for real processing)
DIARIZATION_BACKEND=modal
DIARIZATION_MODAL_API_KEY=<modal-api-key>
# TRANSCRIPTION_BACKEND is not explicitly set (uses default/modal)
```
**2. Services Running:**
```bash
docker compose ps # server, postgres, redis, worker, beat should be UP
```
**IMPORTANT:** Worker and beat services MUST be running for transcription processing:
```bash
docker compose up -d worker beat
```
**3. ngrok Tunnel for Webhooks:**
```bash
# Start ngrok (if not already running)
ngrok http 1250 --log=stdout > /tmp/ngrok.log 2>&1 &
# Get public URL
curl -s http://localhost:4040/api/tunnels | python3 -c "import sys, json; data=json.load(sys.stdin); print(data['tunnels'][0]['public_url'])"
```
**Current ngrok URL:** `https://0503947384a3.ngrok-free.app` (as of last registration)
**4. Webhook Created:**
```bash
cd server
uv run python scripts/recreate_daily_webhook.py https://0503947384a3.ngrok-free.app/v1/daily/webhook
# Verify: "Created webhook <uuid> (state: ACTIVE)"
```
**Current webhook status:** ✅ ACTIVE (webhook ID: dad5ad16-ceca-488e-8fc5-dae8650b51d0)
---
## Test 1: Database Configuration
**Check room platform:**
```bash
docker-compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT id, name, platform, recording_type FROM room WHERE name = 'test2';"
```
**Expected:**
```
id: 552640fd-16f2-4162-9526-8cf40cd2357e
name: test2
platform: whereby # DB value (overridden by env var DAILY_MIGRATION_ROOM_IDS)
recording_type: cloud
```
**Clear old meetings:**
```bash
docker-compose exec -T postgres psql -U reflector -d reflector -c \
"UPDATE meeting SET is_active = false WHERE room_id = '552640fd-16f2-4162-9526-8cf40cd2357e';"
```
---
## Test 2: Meeting Creation with Auto-Recording
**Create meeting:**
```bash
curl -s -X POST http://localhost:1250/v1/rooms/test2/meeting \
-H "Content-Type: application/json" \
-d '{"allow_duplicated":false}' | python3 -m json.tool
```
**Expected Response:**
```json
{
"room_name": "test2-YYYYMMDDHHMMSS", // Includes "test2" prefix!
"room_url": "https://monadical.daily.co/test2-...?t=<JWT_TOKEN>", // Has token!
"platform": "daily",
"recording_type": "cloud" // DB value (Whereby-specific)
}
```
**Decode token to verify auto-recording:**
```bash
# Extract token from room_url, decode JWT payload
echo "<token>" | python3 -c "
import sys, json, base64
token = sys.stdin.read().strip()
payload = token.split('.')[1] + '=' * (4 - len(token.split('.')[1]) % 4)
print(json.dumps(json.loads(base64.b64decode(payload)), indent=2))
"
```
**Expected token payload:**
```json
{
"r": "test2-YYYYMMDDHHMMSS", // Room name
"sr": true, // start_recording: true ✅
"d": "...", // Domain ID
"iat": 1234567890
}
```
---
## Test 3: Daily.co API Verification
**Check room configuration:**
```bash
ROOM_NAME="<from previous step>"
curl -s -X GET "https://api.daily.co/v1/rooms/$ROOM_NAME" \
-H "Authorization: Bearer $DAILY_API_KEY" | python3 -m json.tool
```
**Expected config:**
```json
{
"config": {
"enable_recording": "raw-tracks", // ✅
"recordings_bucket": {
"bucket_name": "reflector-dailyco-local",
"bucket_region": "us-east-1",
"assume_role_arn": "arn:aws:iam::950402358378:role/DailyCo"
}
}
}
```
---
## Test 4: Browser UI Test (Playwright MCP)
**Using Claude Code MCP tools:**
**Load room:**
```
Use: mcp__playwright__browser_navigate
Input: {"url": "http://localhost:3000/test2"}
Then wait 12 seconds for iframe to load
```
**Verify Daily.co iframe loaded:**
```
Use: mcp__playwright__browser_snapshot
Expected in snapshot:
- iframe element with src containing "monadical.daily.co"
- Daily.co pre-call UI visible
```
**Take screenshot:**
```
Use: mcp__playwright__browser_take_screenshot
Input: {"filename": "test2-before-join.png"}
Expected: Daily.co pre-call UI with "Join" button visible
```
**Join meeting:**
```
Note: Daily.co iframe interaction requires clicking inside iframe.
Use: mcp__playwright__browser_click
Input: {"element": "Join button in Daily.co iframe", "ref": "<ref-from-snapshot>"}
Then wait 5 seconds for call to connect
```
**Verify in-call:**
```
Use: mcp__playwright__browser_take_screenshot
Input: {"filename": "test2-in-call.png"}
Expected: "Waiting for others to join" or participant video visible
```
**Leave meeting:**
```
Use: mcp__playwright__browser_click
Input: {"element": "Leave button in Daily.co iframe", "ref": "<ref-from-snapshot>"}
```
---
**Alternative: JavaScript snippets (for manual testing):**
```javascript
await page.goto('http://localhost:3000/test2');
await new Promise(f => setTimeout(f, 12000)); // Wait for load
// Verify iframe
const iframes = document.querySelectorAll('iframe');
// Expected: 1 iframe with src containing "monadical.daily.co"
// Screenshot
await page.screenshot({ path: 'test2-before-join.png' });
// Join
await page.locator('iframe').contentFrame().getByRole('button', { name: 'Join' }).click();
await new Promise(f => setTimeout(f, 5000));
// In-call screenshot
await page.screenshot({ path: 'test2-in-call.png' });
// Leave
await page.locator('iframe').contentFrame().getByRole('button', { name: 'Leave' }).click();
```
---
## Test 5: Webhook Verification
**Check server logs for webhooks:**
```bash
docker-compose logs --since 15m server 2>&1 | grep -i "participant joined\|recording started"
```
**Expected logs:**
```
[info] Participant joined | meeting_id=... | num_clients=1 | recording_type=cloud | recording_trigger=automatic-2nd-participant
[info] Recording started | meeting_id=... | recording_id=... | platform=daily
```
**Check Daily.co webhook delivery logs:**
```bash
curl -s -X GET "https://api.daily.co/v1/logs/webhooks?limit=20" \
-H "Authorization: Bearer $DAILY_API_KEY" | python3 -c "
import sys, json
logs = json.load(sys.stdin)
for log in logs[:10]:
req = json.loads(log['request'])
room = req.get('payload', {}).get('room') or req.get('payload', {}).get('room_name', 'N/A')
print(f\"{req['type']:30s} | room: {room:30s} | status: {log['status']}\")
"
```
**Expected output:**
```
participant.joined | room: test2-YYYYMMDDHHMMSS | status: 200
recording.started | room: test2-YYYYMMDDHHMMSS | status: 200
participant.left | room: test2-YYYYMMDDHHMMSS | status: 200
recording.ready-to-download | room: test2-YYYYMMDDHHMMSS | status: 200
```
**Check database updated:**
```bash
docker-compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT room_name, num_clients FROM meeting WHERE room_name LIKE 'test2-%' ORDER BY end_date DESC LIMIT 1;"
```
**Expected:**
```
room_name: test2-YYYYMMDDHHMMSS
num_clients: 0 // After participant left
```
---
## Test 6: Recording in S3
**List recent recordings:**
```bash
curl -s -X GET "https://api.daily.co/v1/recordings" \
-H "Authorization: Bearer $DAILY_API_KEY" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for rec in data.get('data', [])[:5]:
if 'test2-' in rec.get('room_name', ''):
print(f\"Room: {rec['room_name']}\")
print(f\"Status: {rec['status']}\")
print(f\"Duration: {rec.get('duration', 0)}s\")
print(f\"S3 key: {rec.get('s3key', 'N/A')}\")
print(f\"Tracks: {len(rec.get('tracks', []))} files\")
for track in rec.get('tracks', []):
print(f\" - {track['type']}: {track['s3Key'].split('/')[-1]} ({track['size']} bytes)\")
print()
"
```
**Expected output:**
```
Room: test2-20251009192341
Status: finished
Duration: ~30-120s
S3 key: monadical/test2-20251009192341/1760037914930
Tracks: 2 files
- audio: 1760037914930-<uuid>-cam-audio-1760037915265 (~400 KB)
- video: 1760037914930-<uuid>-cam-video-1760037915269 (~10-30 MB)
```
**Verify S3 path structure:**
- `monadical/` - Daily.co subdomain
- `test2-20251009192341/` - Reflector room name + timestamp
- `<timestamp>-<participant-uuid>-<media-type>-<track-start>.webm` - Individual track files
---
## Test 7: Database Check - Recording and Transcript
**Check recording created:**
```bash
docker-compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT id, bucket_name, object_key, status, meeting_id, recorded_at
FROM recording
ORDER BY recorded_at DESC LIMIT 1;"
```
**Expected:**
```
id: <recording-id-from-webhook>
bucket_name: reflector-dailyco-local
object_key: monadical/test2-<timestamp>/<recording-timestamp>-<uuid>-cam-audio-<track-start>.webm
status: completed
meeting_id: <meeting-id>
recorded_at: <recent-timestamp>
```
**Check transcript created:**
```bash
docker compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT id, title, status, duration, recording_id, meeting_id, room_id
FROM transcript
ORDER BY created_at DESC LIMIT 1;"
```
**Expected (REAL transcription):**
```
id: <transcript-id>
title: <AI-generated title based on actual conversation content>
status: uploaded (audio file processed and available)
duration: <actual meeting duration in seconds>
recording_id: <same-as-recording-id-above>
meeting_id: <meeting-id>
room_id: 552640fd-16f2-4162-9526-8cf40cd2357e
```
**Note:** Title and content will reflect the ACTUAL conversation, not mock data. Processing time depends on recording length and GPU backend availability (Modal).
**Verify audio file exists:**
```bash
ls -lh data/<transcript-id>/upload.webm
```
**Expected:**
```
-rw-r--r-- 1 user staff ~100-200K Oct 10 18:48 upload.webm
```
**Check transcript topics (REAL transcription):**
```bash
TRANSCRIPT_ID=$(docker compose exec -T postgres psql -U reflector -d reflector -t -c \
"SELECT id FROM transcript ORDER BY created_at DESC LIMIT 1;")
docker compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT
jsonb_array_length(topics) as num_topics,
jsonb_array_length(participants) as num_participants,
short_summary,
title
FROM transcript
WHERE id = '$TRANSCRIPT_ID';"
```
**Expected (REAL data):**
```
num_topics: <varies based on conversation>
num_participants: <actual number of participants who spoke>
short_summary: <AI-generated summary of actual conversation>
title: <AI-generated title based on content>
```
**Check topics contain actual transcription:**
```bash
docker compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT topics->0->'title', topics->0->'summary', topics->0->'transcript'
FROM transcript
ORDER BY created_at DESC LIMIT 1;" | head -20
```
**Expected output:** Will contain the ACTUAL transcribed conversation from the Daily.co meeting, not mock data.
**Check participants:**
```bash
docker compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT participants FROM transcript ORDER BY created_at DESC LIMIT 1;" \
| python3 -c "import sys, json; data=json.loads(sys.stdin.read()); print(json.dumps(data, indent=2))"
```
**Expected (REAL diarization):**
```json
[
{
"id": "<uuid>",
"speaker": 0,
"name": "Speaker 1"
},
{
"id": "<uuid>",
"speaker": 1,
"name": "Speaker 2"
}
]
```
**Note:** Speaker names will be generic ("Speaker 1", "Speaker 2", etc.) as determined by the diarization backend. Number of participants depends on how many actually spoke during the meeting.
**Check word-level data:**
```bash
docker compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT jsonb_array_length(topics->0->'words') as num_words_first_topic
FROM transcript
ORDER BY created_at DESC LIMIT 1;"
```
**Expected:**
```
num_words_first_topic: <varies based on actual conversation length and topic chunking>
```
**Verify speaker diarization in words:**
```bash
docker compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT
topics->0->'words'->0->>'text' as first_word,
topics->0->'words'->0->>'speaker' as speaker,
topics->0->'words'->0->>'start' as start_time,
topics->0->'words'->0->>'end' as end_time
FROM transcript
ORDER BY created_at DESC LIMIT 1;"
```
**Expected (REAL transcription):**
```
first_word: <actual first word from transcription>
speaker: 0, 1, 2, ... (actual speaker ID from diarization)
start_time: <actual timestamp in seconds>
end_time: <actual end timestamp>
```
**Note:** All timestamps and speaker IDs are from real transcription/diarization, synchronized across tracks.
---
## Test 8: Recording Type Verification
**Check what Daily.co received:**
```bash
curl -s -X GET "https://api.daily.co/v1/rooms/test2-<timestamp>" \
-H "Authorization: Bearer $DAILY_API_KEY" | python3 -m json.tool | grep "enable_recording"
```
**Expected:**
```json
"enable_recording": "raw-tracks"
```
**NOT:** `"enable_recording": "cloud"` (that would be wrong - we want raw tracks)
---
## Troubleshooting
### Issue: No webhooks received
**Check webhook state:**
```bash
curl -s -X GET "https://api.daily.co/v1/webhooks" \
-H "Authorization: Bearer $DAILY_API_KEY" | python3 -m json.tool
```
**If state is FAILED:**
```bash
cd server
uv run python scripts/recreate_daily_webhook.py https://<ngrok-url>/v1/daily/webhook
```
### Issue: Webhooks return 422
**Check server logs:**
```bash
docker-compose logs --tail=50 server | grep "Failed to parse webhook event"
```
**Common cause:** Event structure mismatch. Daily.co events use:
```json
{
"version": "1.0.0",
"type": "participant.joined",
"payload": {...}, // NOT "data"
"event_ts": 123.456 // NOT "ts"
}
```
### Issue: Recording not starting
1. **Check token has `sr: true`:**
- Decode JWT token from room_url query param
- Should contain `"sr": true`
2. **Check Daily.co room config:**
- `enable_recording` must be set (not false)
- For raw-tracks: must be exactly `"raw-tracks"`
3. **Check participant actually joined:**
- Logs should show "Participant joined"
- Must click "Join" button, not just pre-call screen
### Issue: Recording in S3 but wrong format
**Daily.co recording types:**
- `"cloud"` → Single MP4 file (`download_link` in webhook)
- `"raw-tracks"` → Multiple WebM files (`tracks` array in webhook)
- `"raw-tracks-audio-only"` → Only audio WebM files
**Current implementation:** Always uses `"raw-tracks"` (better for transcription)
---
## Quick Validation Commands
**One-liner to verify everything:**
```bash
# 1. Check room exists
docker-compose exec -T postgres psql -U reflector -d reflector -c \
"SELECT name, platform FROM room WHERE name = 'test2';" && \
# 2. Create meeting
MEETING=$(curl -s -X POST http://localhost:1250/v1/rooms/test2/meeting \
-H "Content-Type: application/json" -d '{"allow_duplicated":false}') && \
echo "$MEETING" | python3 -c "import sys,json; m=json.load(sys.stdin); print(f'Room: {m[\"room_name\"]}\nURL: {m[\"room_url\"][:80]}...')" && \
# 3. Check Daily.co config
ROOM_NAME=$(echo "$MEETING" | python3 -c "import sys,json; print(json.load(sys.stdin)['room_name'])") && \
curl -s -X GET "https://api.daily.co/v1/rooms/$ROOM_NAME" \
-H "Authorization: Bearer $DAILY_API_KEY" | python3 -c "import sys,json; print(f'Recording: {json.load(sys.stdin)[\"config\"][\"enable_recording\"]}')"
```
**Expected output:**
```
name: test2, platform: whereby
Room: test2-20251009192341
URL: https://monadical.daily.co/test2-20251009192341?t=eyJhbGc...
Recording: raw-tracks
```
---
## Success Criteria Checklist
- [x] Room name includes Reflector room prefix (`test2-...`)
- [x] Meeting URL contains JWT token (`?t=...`)
- [x] Token has `sr: true` (auto-recording enabled)
- [x] Daily.co room config: `enable_recording: "raw-tracks"`
- [x] Browser loads Daily.co interface (not Whereby)
- [x] Recording auto-starts when participant joins
- [x] Webhooks received: participant.joined, recording.started, participant.left, recording.ready-to-download
- [x] Recording status: `finished`
- [x] S3 contains 2 files: audio (.webm) and video (.webm)
- [x] S3 path: `monadical/test2-{timestamp}/{recording-start-ts}-{participant-uuid}-cam-{audio|video}-{track-start-ts}`
- [x] Database `num_clients` increments/decrements correctly
- [x] **Database recording entry created** with correct S3 path and status `completed`
- [ ] **Database transcript entry created** with status `uploaded`
- [ ] **Audio file downloaded** to `data/{transcript_id}/upload.webm`
- [ ] **Transcript has REAL data**: AI-generated title based on conversation
- [ ] **Transcript has topics** generated from actual content
- [ ] **Transcript has participants** with proper speaker diarization
- [ ] **Topics contain word-level data** with accurate timestamps and speaker IDs
- [ ] **Total duration** matches actual meeting length
- [ ] **MP3 and waveform files generated** by file processing pipeline
- [ ] **Frontend transcript page loads** without "Failed to load audio" error
- [ ] **Audio player functional** with working playback and waveform visualization
- [ ] **Multitrack processing completed** without errors in worker logs
- [ ] **Modal GPU backends accessible** (transcription and diarization)

View File

@@ -6,7 +6,7 @@ ENV PYTHONUNBUFFERED=1 \
# builder install base dependencies
WORKDIR /tmp
RUN apt-get update && apt-get install -y curl && apt-get clean
RUN apt-get update && apt-get install -y curl ffmpeg && 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"

View File

@@ -14,7 +14,7 @@ Webhooks are configured at the room level with two fields:
### `transcript.completed`
Triggered when a transcript has been fully processed, including transcription, diarization, summarization, and topic detection.
Triggered when a transcript has been fully processed, including transcription, diarization, summarization, topic detection and calendar event integration.
### `test`
@@ -128,6 +128,27 @@ This event includes a convenient URL for accessing the transcript:
"room": {
"id": "room-789",
"name": "Product Team Room"
},
"calendar_event": {
"id": "calendar-event-123",
"ics_uid": "event-123",
"title": "Q3 Product Planning Meeting",
"start_time": "2025-08-27T12:00:00Z",
"end_time": "2025-08-27T12:30:00Z",
"description": "Team discussed Q3 product roadmap, prioritizing mobile app features and API improvements.",
"location": "Conference Room 1",
"attendees": [
{
"id": "participant-1",
"name": "John Doe",
"speaker": "Speaker 1"
},
{
"id": "participant-2",
"name": "Jane Smith",
"speaker": "Speaker 2"
}
]
}
}
```

View File

@@ -27,7 +27,7 @@ AUTH_JWT_AUDIENCE=
#TRANSCRIPT_MODAL_API_KEY=xxxxx
TRANSCRIPT_BACKEND=modal
TRANSCRIPT_URL=https://monadical-sas--reflector-transcriber-web.modal.run
TRANSCRIPT_URL=https://monadical-sas--reflector-transcriber-parakeet-web.modal.run
TRANSCRIPT_MODAL_API_KEY=
## =======================================================
@@ -71,3 +71,27 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run
## Sentry DSN configuration
#SENTRY_DSN=
## =======================================================
## Video Platform Configuration
## =======================================================
## Whereby
#WHEREBY_API_KEY=your-whereby-api-key
#WHEREBY_WEBHOOK_SECRET=your-whereby-webhook-secret
#AWS_WHEREBY_ACCESS_KEY_ID=your-aws-key
#AWS_WHEREBY_ACCESS_KEY_SECRET=your-aws-secret
#AWS_PROCESS_RECORDING_QUEUE_URL=https://sqs.us-west-2.amazonaws.com/...
## Daily.co
#DAILY_API_KEY=your-daily-api-key
#DAILY_WEBHOOK_SECRET=your-daily-webhook-secret
#DAILY_SUBDOMAIN=your-subdomain
#AWS_DAILY_S3_BUCKET=your-daily-bucket
#AWS_DAILY_S3_REGION=us-west-2
#AWS_DAILY_ROLE_ARN=arn:aws:iam::ACCOUNT:role/DailyRecording
## Platform Selection
#DAILY_MIGRATION_ENABLED=false # Enable Daily.co support
#DAILY_MIGRATION_ROOM_IDS=[] # Specific rooms to use Daily
#DEFAULT_VIDEO_PLATFORM=whereby # Default platform for new rooms

View File

@@ -0,0 +1,50 @@
"""add_platform_support
Revision ID: 1e49625677e4
Revises: dc035ff72fd5
Create Date: 2025-10-08 13:17:29.943612
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1e49625677e4"
down_revision: Union[str, None] = "dc035ff72fd5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add platform field with default 'whereby' for backward compatibility."""
with op.batch_alter_table("room", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"platform",
sa.String(),
nullable=False,
server_default="whereby",
)
)
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"platform",
sa.String(),
nullable=False,
server_default="whereby",
)
)
def downgrade() -> None:
"""Remove platform field."""
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.drop_column("platform")
with op.batch_alter_table("room", schema=None) as batch_op:
batch_op.drop_column("platform")

View File

@@ -112,6 +112,7 @@ source = ["reflector"]
[tool.pytest_env]
ENVIRONMENT = "pytest"
DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test"
AUTH_BACKEND = "jwt"
[tool.pytest.ini_options]
addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v"

View File

@@ -12,6 +12,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
from reflector.logger import logger
from reflector.metrics import metrics_init
from reflector.settings import settings
from reflector.views.daily import router as daily_router
from reflector.views.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router
from reflector.views.rtc_offer import router as rtc_offer_router
@@ -26,6 +27,7 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout
from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router
from reflector.views.transcripts_websocket import router as transcripts_websocket_router
from reflector.views.user import router as user_router
from reflector.views.user_websocket import router as user_ws_router
from reflector.views.whereby import router as whereby_router
from reflector.views.zulip import router as zulip_router
@@ -65,6 +67,12 @@ app.add_middleware(
allow_headers=["*"],
)
@app.get("/health")
async def health():
return {"status": "healthy"}
# metrics
instrumentator = Instrumentator(
excluded_handlers=["/docs", "/metrics"],
@@ -84,8 +92,10 @@ 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_ws_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1")
app.include_router(daily_router, prefix="/v1/daily")
add_pagination(app)
# prepare celery

View File

@@ -104,6 +104,11 @@ class CalendarEventController:
results = await get_database().fetch_all(query)
return [CalendarEvent(**result) for result in results]
async def get_by_id(self, event_id: str) -> CalendarEvent | None:
query = calendar_events.select().where(calendar_events.c.id == event_id)
result = await get_database().fetch_one(query)
return CalendarEvent(**result) if result else None
async def get_by_ics_uid(self, room_id: str, ics_uid: str) -> CalendarEvent | None:
query = calendar_events.select().where(
sa.and_(

View File

@@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from reflector.db import get_database, metadata
from reflector.db.rooms import Room
from reflector.platform_types import Platform
from reflector.utils import generate_uuid4
meetings = sa.Table(
@@ -55,6 +56,12 @@ meetings = sa.Table(
),
),
sa.Column("calendar_metadata", JSONB),
sa.Column(
"platform",
sa.String,
nullable=False,
server_default="whereby",
),
sa.Index("idx_meeting_room_id", "room_id"),
sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
)
@@ -94,13 +101,14 @@ class Meeting(BaseModel):
is_locked: bool = False
room_mode: Literal["normal", "group"] = "normal"
recording_type: Literal["none", "local", "cloud"] = "cloud"
recording_trigger: Literal[
recording_trigger: Literal[ # whereby-specific
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
num_clients: int = 0
is_active: bool = True
calendar_event_id: str | None = None
calendar_metadata: dict[str, Any] | None = None
platform: Platform = "whereby"
class MeetingController:
@@ -115,6 +123,7 @@ class MeetingController:
room: Room,
calendar_event_id: str | None = None,
calendar_metadata: dict[str, Any] | None = None,
platform: Platform = "whereby",
):
meeting = Meeting(
id=id,
@@ -130,6 +139,7 @@ class MeetingController:
recording_trigger=room.recording_trigger,
calendar_event_id=calendar_event_id,
calendar_metadata=calendar_metadata,
platform=platform,
)
query = meetings.insert().values(**meeting.model_dump())
await get_database().execute(query)
@@ -239,6 +249,28 @@ class MeetingController:
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
await get_database().execute(query)
async def increment_num_clients(self, meeting_id: str):
"""Atomically increment participant count."""
query = (
meetings.update()
.where(meetings.c.id == meeting_id)
.values(num_clients=meetings.c.num_clients + 1)
)
await get_database().execute(query)
async def decrement_num_clients(self, meeting_id: str):
"""Atomically decrement participant count (min 0)."""
query = (
meetings.update()
.where(meetings.c.id == meeting_id)
.values(
num_clients=sa.case(
(meetings.c.num_clients > 0, meetings.c.num_clients - 1), else_=0
)
)
)
await get_database().execute(query)
class MeetingConsentController:
async def get_by_meeting_id(self, meeting_id: str) -> list[MeetingConsent]:

View File

@@ -1,7 +1,7 @@
import secrets
from datetime import datetime, timezone
from sqlite3 import IntegrityError
from typing import Literal
from typing import Literal, Optional
import sqlalchemy
from fastapi import HTTPException
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.sql import false, or_
from reflector.db import get_database, metadata
from reflector.platform_types import Platform
from reflector.utils import generate_uuid4
rooms = sqlalchemy.Table(
@@ -50,6 +51,12 @@ rooms = sqlalchemy.Table(
),
sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)),
sqlalchemy.Column("ics_last_etag", sqlalchemy.Text),
sqlalchemy.Column(
"platform",
sqlalchemy.String,
nullable=False,
server_default="whereby",
),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -66,7 +73,7 @@ class Room(BaseModel):
is_locked: bool = False
room_mode: Literal["normal", "group"] = "normal"
recording_type: Literal["none", "local", "cloud"] = "cloud"
recording_trigger: Literal[
recording_trigger: Literal[ # whereby-specific
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
is_shared: bool = False
@@ -77,6 +84,7 @@ class Room(BaseModel):
ics_enabled: bool = False
ics_last_sync: datetime | None = None
ics_last_etag: str | None = None
platform: Platform = "whereby"
class RoomController:
@@ -130,6 +138,7 @@ class RoomController:
ics_url: str | None = None,
ics_fetch_interval: int = 300,
ics_enabled: bool = False,
platform: Optional[Platform] = None,
):
"""
Add a new room
@@ -153,6 +162,7 @@ class RoomController:
ics_url=ics_url,
ics_fetch_interval=ics_fetch_interval,
ics_enabled=ics_enabled,
platform=platform or "whereby",
)
query = rooms.insert().values(**room.model_dump())
try:

View File

@@ -647,6 +647,19 @@ class TranscriptController:
query = transcripts.delete().where(transcripts.c.recording_id == recording_id)
await get_database().execute(query)
@staticmethod
def user_can_mutate(transcript: Transcript, user_id: str | None) -> bool:
"""
Returns True if the given user is allowed to modify the transcript.
Policy:
- Anonymous transcripts (user_id is None) cannot be modified via API
- Only the owner (matching user_id) can modify their transcript
"""
if transcript.user_id is None:
return False
return user_id and transcript.user_id == user_id
@asynccontextmanager
async def transaction(self):
"""

View File

@@ -0,0 +1,84 @@
# Multitrack Pipeline Fix Summary
## Problem
Whisper timestamps were incorrect because it ignores leading silence in audio files. Daily.co tracks can have arbitrary amounts of silence before speech starts.
## Solution
**Pad tracks BEFORE transcription using stream metadata `start_time`**
This makes Whisper timestamps automatically correct relative to recording start.
## Key Changes in `main_multitrack_pipeline_fixed.py`
### 1. Added `pad_track_for_transcription()` method (lines 55-172)
```python
async def pad_track_for_transcription(
self,
track_data: bytes,
track_idx: int,
storage,
) -> tuple[bytes, str]:
```
- Extracts stream metadata `start_time` using PyAV
- Creates PyAV filter graph with `adelay` filter to add padding
- Stores padded track to S3 and returns URL
- Uses same audio processing library (PyAV) already in the pipeline
### 2. Modified `process()` method
#### REMOVED (lines 255-302):
- Entire filename parsing for offsets - NOT NEEDED ANYMORE
- The complex regex parsing of Daily.co filenames
- Offset adjustment after transcription
#### ADDED (lines 371-382):
- Padding step BEFORE transcription:
```python
# PAD TRACKS BEFORE TRANSCRIPTION - THIS IS THE KEY FIX!
padded_track_urls: list[str] = []
for idx, data in enumerate(track_datas):
if not data:
padded_track_urls.append("")
continue
_, padded_url = await self.pad_track_for_transcription(
data, idx, storage
)
padded_track_urls.append(padded_url)
```
#### MODIFIED (lines 385-435):
- Transcribe PADDED tracks instead of raw tracks
- Removed all timestamp offset adjustment code
- Just set speaker ID - timestamps already correct!
```python
# NO OFFSET ADJUSTMENT NEEDED!
# Timestamps are already correct because we transcribed padded tracks
# Just set speaker ID
for w in t.words:
w.speaker = idx
```
## Why This Works
1. **Stream metadata is authoritative**: Daily.co sets `start_time` in the WebM container
2. **PyAV respects metadata**: `audio_stream.start_time * audio_stream.time_base` gives seconds
3. **Padding before transcription**: Whisper sees continuous audio from time 0
4. **Automatic alignment**: Word at 51s in padded track = 51s in recording
## Testing
Process the test recording (daily-20251020193458) and verify:
- Participant 0 words appear at ~2s
- Participant 1 words appear at ~51s
- No word interleaving
- Correct chronological order
## Files
- **Original**: `main_multitrack_pipeline.py`
- **Fixed**: `main_multitrack_pipeline_fixed.py`
- **Test data**: `/Users/firfi/work/clients/monadical/reflector/1760988935484-*.webm`

View File

@@ -131,7 +131,7 @@ class PipelineMainFile(PipelineMainBase):
self.logger.info("File pipeline complete")
await transcripts_controller.set_status(transcript.id, "ended")
await self.set_status(transcript.id, "ended")
async def extract_and_write_audio(
self, file_path: Path, transcript: Transcript

View File

@@ -85,6 +85,20 @@ def broadcast_to_sockets(func):
message=resp.model_dump(mode="json"),
)
transcript = await transcripts_controller.get_by_id(self.transcript_id)
if transcript and transcript.user_id:
# Emit only relevant events to the user room to avoid noisy updates.
# Allowed: STATUS, FINAL_TITLE, DURATION. All are prefixed with TRANSCRIPT_
allowed_user_events = {"STATUS", "FINAL_TITLE", "DURATION"}
if resp.event in allowed_user_events:
await self.ws_manager.send_json(
room_id=f"user:{transcript.user_id}",
message={
"event": f"TRANSCRIPT_{resp.event}",
"data": {"id": self.transcript_id, **resp.data},
},
)
return wrapper

View File

@@ -0,0 +1,510 @@
import asyncio
import io
from fractions import Fraction
import av
import boto3
import structlog
from av.audio.resampler import AudioResampler
from celery import chain, shared_task
from reflector.asynctask import asynctask
from reflector.db.transcripts import (
TranscriptStatus,
TranscriptText,
transcripts_controller,
)
from reflector.logger import logger
from reflector.pipelines.main_file_pipeline import task_send_webhook_if_needed
from reflector.pipelines.main_live_pipeline import (
PipelineMainBase,
task_cleanup_consent,
task_pipeline_post_to_zulip,
)
from reflector.processors import (
AudioFileWriterProcessor,
TranscriptFinalSummaryProcessor,
TranscriptFinalTitleProcessor,
TranscriptTopicDetectorProcessor,
)
from reflector.processors.file_transcript import FileTranscriptInput
from reflector.processors.file_transcript_auto import FileTranscriptAutoProcessor
from reflector.processors.types import TitleSummary
from reflector.processors.types import (
Transcript as TranscriptType,
)
from reflector.settings import settings
from reflector.storage import get_transcripts_storage
class EmptyPipeline:
def __init__(self, logger: structlog.BoundLogger):
self.logger = logger
def get_pref(self, k, d=None):
return d
async def emit(self, event):
pass
class PipelineMainMultitrack(PipelineMainBase):
"""Process multiple participant tracks for a transcript without mixing audio."""
def __init__(self, transcript_id: str):
super().__init__(transcript_id=transcript_id)
self.logger = logger.bind(transcript_id=self.transcript_id)
self.empty_pipeline = EmptyPipeline(logger=self.logger)
async def mixdown_tracks(
self,
track_datas: list[bytes],
writer: AudioFileWriterProcessor,
offsets_seconds: list[float] | None = None,
) -> None:
"""
Minimal multi-track mixdown using a PyAV filter graph (amix), no resampling.
"""
# Discover target sample rate from first decodable frame
target_sample_rate: int | None = None
for data in track_datas:
if not data:
continue
try:
container = av.open(io.BytesIO(data))
try:
for frame in container.decode(audio=0):
target_sample_rate = frame.sample_rate
break
finally:
container.close()
except Exception:
continue
if target_sample_rate:
break
if not target_sample_rate:
self.logger.warning("Mixdown skipped - no decodable audio frames found")
return
# Build PyAV filter graph:
# N abuffer (s32/stereo)
# -> optional adelay per input (for alignment)
# -> amix (s32)
# -> aformat(s16)
# -> sink
graph = av.filter.Graph()
inputs = []
valid_track_datas = [d for d in track_datas if d]
# Align offsets list with the filtered inputs (skip empties)
input_offsets_seconds = None
if offsets_seconds is not None:
input_offsets_seconds = [
offsets_seconds[i] for i, d in enumerate(track_datas) if d
]
for idx, data in enumerate(valid_track_datas):
args = (
f"time_base=1/{target_sample_rate}:"
f"sample_rate={target_sample_rate}:"
f"sample_fmt=s32:"
f"channel_layout=stereo"
)
in_ctx = graph.add("abuffer", args=args, name=f"in{idx}")
inputs.append(in_ctx)
if not inputs:
self.logger.warning("Mixdown skipped - no valid inputs for graph")
return
mixer = graph.add("amix", args=f"inputs={len(inputs)}:normalize=0", name="mix")
fmt = graph.add(
"aformat",
args=(
f"sample_fmts=s32:channel_layouts=stereo:sample_rates={target_sample_rate}"
),
name="fmt",
)
sink = graph.add("abuffersink", name="out")
# Optional per-input delay before mixing
delays_ms: list[int] = []
if input_offsets_seconds is not None:
base = min(input_offsets_seconds) if input_offsets_seconds else 0.0
delays_ms = [
max(0, int(round((o - base) * 1000))) for o in input_offsets_seconds
]
else:
delays_ms = [0 for _ in inputs]
for idx, in_ctx in enumerate(inputs):
delay_ms = delays_ms[idx] if idx < len(delays_ms) else 0
if delay_ms > 0:
# adelay requires one value per channel; use same for stereo
adelay = graph.add(
"adelay",
args=f"delays={delay_ms}|{delay_ms}:all=1",
name=f"delay{idx}",
)
in_ctx.link_to(adelay)
adelay.link_to(mixer, 0, idx)
else:
in_ctx.link_to(mixer, 0, idx)
mixer.link_to(fmt)
fmt.link_to(sink)
graph.configure()
# Open containers for decoding
containers = []
for i, d in enumerate(valid_track_datas):
try:
c = av.open(io.BytesIO(d))
containers.append(c)
except Exception as e:
self.logger.warning(
"Mixdown: failed to open container", input=i, error=str(e)
)
containers.append(None)
# Filter out Nones for decoders
containers = [c for c in containers if c is not None]
decoders = [c.decode(audio=0) for c in containers]
active = [True] * len(decoders)
# Per-input resamplers to enforce s32/stereo at the same rate (no resample of rate)
resamplers = [
AudioResampler(format="s32", layout="stereo", rate=target_sample_rate)
for _ in decoders
]
try:
# Round-robin feed frames into graph, pull mixed frames as they become available
while any(active):
for i, (dec, is_active) in enumerate(zip(decoders, active)):
if not is_active:
continue
try:
frame = next(dec)
except StopIteration:
active[i] = False
continue
# Enforce same sample rate; convert format/layout to s16/stereo (no resample)
if frame.sample_rate != target_sample_rate:
# Skip frames with differing 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)
# Drain available mixed frames
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
await writer.push(mixed)
# Signal EOF to inputs and drain remaining
for in_ctx in inputs:
in_ctx.push(None)
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
await writer.push(mixed)
finally:
for c in containers:
c.close()
async def set_status(self, transcript_id: str, status: TranscriptStatus):
async with self.lock_transaction():
return await transcripts_controller.set_status(transcript_id, status)
async def process(self, bucket_name: str, track_keys: list[str]):
transcript = await self.get_transcript()
s3 = boto3.client(
"s3",
region_name=settings.RECORDING_STORAGE_AWS_REGION,
aws_access_key_id=settings.RECORDING_STORAGE_AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.RECORDING_STORAGE_AWS_SECRET_ACCESS_KEY,
)
storage = get_transcripts_storage()
# Pre-download bytes for all tracks for mixing and transcription
track_datas: list[bytes] = []
for key in track_keys:
try:
obj = s3.get_object(Bucket=bucket_name, Key=key)
track_datas.append(obj["Body"].read())
except Exception as e:
self.logger.warning(
"Skipping track - cannot read S3 object", key=key, error=str(e)
)
track_datas.append(b"")
# Extract offsets from Daily.co filename timestamps
# Format: {rec_start_ts}-{uuid}-{media_type}-{track_start_ts}.{ext}
# Example: 1760988935484-uuid-cam-audio-1760988935922
import re
offsets_seconds: list[float] = []
recording_start_ts: int | None = None
for key in track_keys:
# Parse Daily.co raw-tracks filename pattern
match = re.search(r"(\d+)-([0-9a-f-]{36})-(cam-audio)-(\d+)", key)
if not match:
self.logger.warning(
"Track key doesn't match Daily.co pattern, using 0.0 offset",
key=key,
)
offsets_seconds.append(0.0)
continue
rec_start_ts = int(match.group(1))
track_start_ts = int(match.group(4))
# Validate all tracks belong to same recording
if recording_start_ts is None:
recording_start_ts = rec_start_ts
elif rec_start_ts != recording_start_ts:
self.logger.error(
"Track belongs to different recording",
key=key,
expected_start=recording_start_ts,
got_start=rec_start_ts,
)
offsets_seconds.append(0.0)
continue
# Calculate offset in seconds
offset_ms = track_start_ts - rec_start_ts
offset_s = offset_ms / 1000.0
self.logger.info(
"Parsed track offset from filename",
key=key,
recording_start=rec_start_ts,
track_start=track_start_ts,
offset_seconds=offset_s,
)
offsets_seconds.append(max(0.0, offset_s))
# Mixdown all available tracks into transcript.audio_mp3_filename, preserving sample rate
try:
mp3_writer = AudioFileWriterProcessor(
path=str(transcript.audio_mp3_filename)
)
await self.mixdown_tracks(track_datas, mp3_writer, offsets_seconds)
await mp3_writer.flush()
except Exception as e:
self.logger.error("Mixdown failed", error=str(e))
speaker_transcripts: list[TranscriptType] = []
for idx, key in enumerate(track_keys):
ext = ".mp4"
try:
obj = s3.get_object(Bucket=bucket_name, Key=key)
data = obj["Body"].read()
except Exception as e:
self.logger.error(
"Skipping track - cannot read S3 object", key=key, error=str(e)
)
continue
storage_path = f"file_pipeline/{transcript.id}/tracks/track_{idx}{ext}"
try:
await storage.put_file(storage_path, data)
audio_url = await storage.get_file_url(storage_path)
except Exception as e:
self.logger.error(
"Skipping track - cannot upload to storage", key=key, error=str(e)
)
continue
try:
t = await self.transcribe_file(audio_url, transcript.source_language)
except Exception as e:
self.logger.error(
"Transcription via default backend failed, trying local whisper",
key=key,
url=audio_url,
error=str(e),
)
try:
fallback = FileTranscriptAutoProcessor(name="whisper")
result = None
async def capture_result(r):
nonlocal result
result = r
fallback.on(capture_result)
await fallback.push(
FileTranscriptInput(
audio_url=audio_url, language=transcript.source_language
)
)
await fallback.flush()
if not result:
raise Exception("No transcript captured in fallback")
t = result
except Exception as e2:
self.logger.error(
"Skipping track - transcription failed after fallback",
key=key,
url=audio_url,
error=str(e2),
)
continue
if not t.words:
continue
# Shift word timestamps by the track's offset so all are relative to 00:00
track_offset = offsets_seconds[idx] if idx < len(offsets_seconds) else 0.0
for w in t.words:
try:
if hasattr(w, "start") and w.start is not None:
w.start = float(w.start) + track_offset
if hasattr(w, "end") and w.end is not None:
w.end = float(w.end) + track_offset
except Exception:
pass
w.speaker = idx
speaker_transcripts.append(t)
if not speaker_transcripts:
raise Exception("No valid track transcriptions")
merged_words = []
for t in speaker_transcripts:
merged_words.extend(t.words)
merged_words.sort(key=lambda w: w.start)
merged_transcript = TranscriptType(words=merged_words, translation=None)
await transcripts_controller.append_event(
transcript,
event="TRANSCRIPT",
data=TranscriptText(
text=merged_transcript.text, translation=merged_transcript.translation
),
)
topics = await self.detect_topics(merged_transcript, transcript.target_language)
await asyncio.gather(
self.generate_title(topics),
self.generate_summaries(topics),
return_exceptions=False,
)
await self.set_status(transcript.id, "ended")
async def transcribe_file(self, audio_url: str, language: str) -> TranscriptType:
processor = FileTranscriptAutoProcessor()
input_data = FileTranscriptInput(audio_url=audio_url, language=language)
result: TranscriptType | None = None
async def capture_result(transcript):
nonlocal result
result = transcript
processor.on(capture_result)
await processor.push(input_data)
await processor.flush()
if not result:
raise ValueError("No transcript captured")
return result
async def detect_topics(
self, transcript: TranscriptType, target_language: str
) -> list[TitleSummary]:
chunk_size = 300
topics: list[TitleSummary] = []
async def on_topic(topic: TitleSummary):
topics.append(topic)
return await self.on_topic(topic)
topic_detector = TranscriptTopicDetectorProcessor(callback=on_topic)
topic_detector.set_pipeline(self.empty_pipeline)
for i in range(0, len(transcript.words), chunk_size):
chunk_words = transcript.words[i : i + chunk_size]
if not chunk_words:
continue
chunk_transcript = TranscriptType(
words=chunk_words, translation=transcript.translation
)
await topic_detector.push(chunk_transcript)
await topic_detector.flush()
return topics
async def generate_title(self, topics: list[TitleSummary]):
if not topics:
self.logger.warning("No topics for title generation")
return
processor = TranscriptFinalTitleProcessor(callback=self.on_title)
processor.set_pipeline(self.empty_pipeline)
for topic in topics:
await processor.push(topic)
await processor.flush()
async def generate_summaries(self, topics: list[TitleSummary]):
if not topics:
self.logger.warning("No topics for summary generation")
return
transcript = await self.get_transcript()
processor = TranscriptFinalSummaryProcessor(
transcript=transcript,
callback=self.on_long_summary,
on_short_summary=self.on_short_summary,
)
processor.set_pipeline(self.empty_pipeline)
for topic in topics:
await processor.push(topic)
await processor.flush()
@shared_task
@asynctask
async def task_pipeline_multitrack_process(
*, transcript_id: str, bucket_name: str, track_keys: list[str]
):
pipeline = PipelineMainMultitrack(transcript_id=transcript_id)
try:
await pipeline.set_status(transcript_id, "processing")
await pipeline.process(bucket_name, track_keys)
except Exception:
await pipeline.set_status(transcript_id, "error")
raise
post_chain = chain(
task_cleanup_consent.si(transcript_id=transcript_id),
task_pipeline_post_to_zulip.si(transcript_id=transcript_id),
task_send_webhook_if_needed.si(transcript_id=transcript_id),
)
post_chain.delay()

View File

@@ -0,0 +1,654 @@
import asyncio
import io
from fractions import Fraction
import av
import boto3
import structlog
from av.audio.resampler import AudioResampler
from celery import chain, shared_task
from reflector.asynctask import asynctask
from reflector.db.transcripts import (
TranscriptStatus,
TranscriptWaveform,
transcripts_controller,
)
from reflector.logger import logger
from reflector.pipelines.main_file_pipeline import task_send_webhook_if_needed
from reflector.pipelines.main_live_pipeline import (
PipelineMainBase,
broadcast_to_sockets,
task_cleanup_consent,
task_pipeline_post_to_zulip,
)
from reflector.processors import (
AudioFileWriterProcessor,
TranscriptFinalSummaryProcessor,
TranscriptFinalTitleProcessor,
TranscriptTopicDetectorProcessor,
)
from reflector.processors.audio_waveform_processor import AudioWaveformProcessor
from reflector.processors.file_transcript import FileTranscriptInput
from reflector.processors.file_transcript_auto import FileTranscriptAutoProcessor
from reflector.processors.types import TitleSummary
from reflector.processors.types import (
Transcript as TranscriptType,
)
from reflector.settings import settings
from reflector.storage import get_transcripts_storage
class EmptyPipeline:
def __init__(self, logger: structlog.BoundLogger):
self.logger = logger
def get_pref(self, k, d=None):
return d
async def emit(self, event):
pass
class PipelineMainMultitrack(PipelineMainBase):
"""Process multiple participant tracks for a transcript without mixing audio."""
def __init__(self, transcript_id: str):
super().__init__(transcript_id=transcript_id)
self.logger = logger.bind(transcript_id=self.transcript_id)
self.empty_pipeline = EmptyPipeline(logger=self.logger)
async def pad_track_for_transcription(
self,
track_data: bytes,
track_idx: int,
storage,
) -> tuple[bytes, str]:
"""
Pad a single track with silence based on stream metadata start_time.
This ensures Whisper timestamps will be relative to recording start.
Uses ffmpeg subprocess approach proven to work with python-raw-tracks-align.
Returns: (padded_data, storage_url)
"""
import json
import math
import subprocess
import tempfile
if not track_data:
return b"", ""
transcript = await self.get_transcript()
# Create temp files for ffmpeg processing
with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as input_file:
input_file.write(track_data)
input_file_path = input_file.name
output_file_path = input_file_path.replace(".webm", "_padded.webm")
try:
# Get stream metadata using ffprobe
ffprobe_cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"stream=start_time",
"-of",
"json",
input_file_path,
]
result = subprocess.run(
ffprobe_cmd, capture_output=True, text=True, check=True
)
metadata = json.loads(result.stdout)
# Extract start_time from stream metadata
start_time_seconds = 0.0
if metadata.get("streams") and len(metadata["streams"]) > 0:
start_time_str = metadata["streams"][0].get("start_time", "0")
start_time_seconds = float(start_time_str)
self.logger.info(
f"Track {track_idx} stream metadata: start_time={start_time_seconds:.3f}s",
track_idx=track_idx,
)
# If no padding needed, use original
if start_time_seconds <= 0:
storage_path = f"file_pipeline/{transcript.id}/tracks/original_track_{track_idx}.webm"
await storage.put_file(storage_path, track_data)
url = await storage.get_file_url(storage_path)
return track_data, url
# Calculate delay in milliseconds
delay_ms = math.floor(start_time_seconds * 1000)
# Run ffmpeg to pad the audio while maintaining WebM/Opus format for Modal compatibility
# ffmpeg quirk: aresample needs to come before adelay in the filter chain
ffmpeg_cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-y", # overwrite output
"-i",
input_file_path,
"-af",
f"aresample=async=1,adelay={delay_ms}:all=true",
"-c:a",
"libopus", # Keep Opus codec for Modal compatibility
"-b:a",
"128k", # Standard bitrate for Opus
output_file_path,
]
self.logger.info(
f"Padding track {track_idx} with {delay_ms}ms delay using ffmpeg",
track_idx=track_idx,
delay_ms=delay_ms,
command=" ".join(ffmpeg_cmd),
)
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
if result.returncode != 0:
self.logger.error(
f"ffmpeg padding failed for track {track_idx}",
track_idx=track_idx,
stderr=result.stderr,
returncode=result.returncode,
)
raise Exception(f"ffmpeg padding failed: {result.stderr}")
# Read the padded output
with open(output_file_path, "rb") as f:
padded_data = f.read()
# Store padded track
storage_path = (
f"file_pipeline/{transcript.id}/tracks/padded_track_{track_idx}.webm"
)
await storage.put_file(storage_path, padded_data)
padded_url = await storage.get_file_url(storage_path)
self.logger.info(
f"Successfully padded track {track_idx} with {start_time_seconds:.3f}s offset, stored at {storage_path}",
track_idx=track_idx,
delay_ms=delay_ms,
padded_url=padded_url,
padded_size=len(padded_data),
)
return padded_data, padded_url
finally:
# Clean up temp files
import os
try:
os.unlink(input_file_path)
except:
pass
try:
os.unlink(output_file_path)
except:
pass
async def mixdown_tracks(
self,
track_datas: list[bytes],
writer: AudioFileWriterProcessor,
offsets_seconds: list[float] | None = None,
) -> None:
"""
Minimal multi-track mixdown using a PyAV filter graph (amix), no resampling.
"""
# Discover target sample rate from first decodable frame
target_sample_rate: int | None = None
for data in track_datas:
if not data:
continue
try:
container = av.open(io.BytesIO(data))
try:
for frame in container.decode(audio=0):
target_sample_rate = frame.sample_rate
break
finally:
container.close()
except Exception:
continue
if target_sample_rate:
break
if not target_sample_rate:
self.logger.warning("Mixdown skipped - no decodable audio frames found")
return
# Build PyAV filter graph:
# N abuffer (s32/stereo)
# -> optional adelay per input (for alignment)
# -> amix (s32)
# -> aformat(s16)
# -> sink
graph = av.filter.Graph()
inputs = []
valid_track_datas = [d for d in track_datas if d]
# Align offsets list with the filtered inputs (skip empties)
input_offsets_seconds = None
if offsets_seconds is not None:
input_offsets_seconds = [
offsets_seconds[i] for i, d in enumerate(track_datas) if d
]
for idx, data in enumerate(valid_track_datas):
args = (
f"time_base=1/{target_sample_rate}:"
f"sample_rate={target_sample_rate}:"
f"sample_fmt=s32:"
f"channel_layout=stereo"
)
in_ctx = graph.add("abuffer", args=args, name=f"in{idx}")
inputs.append(in_ctx)
if not inputs:
self.logger.warning("Mixdown skipped - no valid inputs for graph")
return
mixer = graph.add("amix", args=f"inputs={len(inputs)}:normalize=0", name="mix")
fmt = graph.add(
"aformat",
args=(
f"sample_fmts=s32:channel_layouts=stereo:sample_rates={target_sample_rate}"
),
name="fmt",
)
sink = graph.add("abuffersink", name="out")
# Optional per-input delay before mixing
delays_ms: list[int] = []
if input_offsets_seconds is not None:
base = min(input_offsets_seconds) if input_offsets_seconds else 0.0
delays_ms = [
max(0, int(round((o - base) * 1000))) for o in input_offsets_seconds
]
else:
delays_ms = [0 for _ in inputs]
for idx, in_ctx in enumerate(inputs):
delay_ms = delays_ms[idx] if idx < len(delays_ms) else 0
if delay_ms > 0:
# adelay requires one value per channel; use same for stereo
adelay = graph.add(
"adelay",
args=f"delays={delay_ms}|{delay_ms}:all=1",
name=f"delay{idx}",
)
in_ctx.link_to(adelay)
adelay.link_to(mixer, 0, idx)
else:
in_ctx.link_to(mixer, 0, idx)
mixer.link_to(fmt)
fmt.link_to(sink)
graph.configure()
# Open containers for decoding
containers = []
for i, d in enumerate(valid_track_datas):
try:
c = av.open(io.BytesIO(d))
containers.append(c)
except Exception as e:
self.logger.warning(
"Mixdown: failed to open container", input=i, error=str(e)
)
containers.append(None)
# Filter out Nones for decoders
containers = [c for c in containers if c is not None]
decoders = [c.decode(audio=0) for c in containers]
active = [True] * len(decoders)
# Per-input resamplers to enforce s32/stereo at the same rate (no resample of rate)
resamplers = [
AudioResampler(format="s32", layout="stereo", rate=target_sample_rate)
for _ in decoders
]
try:
# Round-robin feed frames into graph, pull mixed frames as they become available
while any(active):
for i, (dec, is_active) in enumerate(zip(decoders, active)):
if not is_active:
continue
try:
frame = next(dec)
except StopIteration:
active[i] = False
continue
# Enforce same sample rate; convert format/layout to s16/stereo (no resample)
if frame.sample_rate != target_sample_rate:
# Skip frames with differing 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)
# Drain available mixed frames
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
await writer.push(mixed)
# Signal EOF to inputs and drain remaining
for in_ctx in inputs:
in_ctx.push(None)
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
await writer.push(mixed)
finally:
for c in containers:
c.close()
@broadcast_to_sockets
async def set_status(self, transcript_id: str, status: TranscriptStatus):
async with self.lock_transaction():
return await transcripts_controller.set_status(transcript_id, status)
async def on_waveform(self, data):
async with self.transaction():
waveform = TranscriptWaveform(waveform=data)
transcript = await self.get_transcript()
return await transcripts_controller.append_event(
transcript=transcript, event="WAVEFORM", data=waveform
)
async def process(self, bucket_name: str, track_keys: list[str]):
transcript = await self.get_transcript()
s3 = boto3.client(
"s3",
region_name=settings.RECORDING_STORAGE_AWS_REGION,
aws_access_key_id=settings.RECORDING_STORAGE_AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.RECORDING_STORAGE_AWS_SECRET_ACCESS_KEY,
)
storage = get_transcripts_storage()
# Pre-download bytes for all tracks for mixing and transcription
track_datas: list[bytes] = []
for key in track_keys:
try:
obj = s3.get_object(Bucket=bucket_name, Key=key)
track_datas.append(obj["Body"].read())
except Exception as e:
self.logger.warning(
"Skipping track - cannot read S3 object", key=key, error=str(e)
)
track_datas.append(b"")
# PAD TRACKS FIRST - this creates full-length tracks with correct timeline
padded_track_datas: list[bytes] = []
padded_track_urls: list[str] = []
for idx, data in enumerate(track_datas):
if not data:
padded_track_datas.append(b"")
padded_track_urls.append("")
continue
padded_data, padded_url = await self.pad_track_for_transcription(
data, idx, storage
)
padded_track_datas.append(padded_data)
padded_track_urls.append(padded_url)
self.logger.info(f"Padded track {idx} for transcription: {padded_url}")
# Mixdown PADDED tracks (already aligned with timeline) into transcript.audio_mp3_filename
try:
# Ensure data directory exists
transcript.data_path.mkdir(parents=True, exist_ok=True)
mp3_writer = AudioFileWriterProcessor(
path=str(transcript.audio_mp3_filename),
on_duration=self.on_duration,
)
# Use PADDED tracks with NO additional offsets (already aligned by padding)
await self.mixdown_tracks(
padded_track_datas, mp3_writer, offsets_seconds=None
)
await mp3_writer.flush()
# Upload the mixed audio to S3 for web playback
if transcript.audio_mp3_filename.exists():
mp3_data = transcript.audio_mp3_filename.read_bytes()
storage_path = f"{transcript.id}/audio.mp3"
await storage.put_file(storage_path, mp3_data)
mp3_url = await storage.get_file_url(storage_path)
# Update transcript to indicate audio is in storage
await transcripts_controller.update(
transcript, {"audio_location": "storage"}
)
self.logger.info(
f"Uploaded mixed audio to storage",
storage_path=storage_path,
size=len(mp3_data),
url=mp3_url,
)
else:
self.logger.warning("Mixdown file does not exist after processing")
except Exception as e:
self.logger.error("Mixdown failed", error=str(e), exc_info=True)
# Generate waveform from the mixed audio file
if transcript.audio_mp3_filename.exists():
try:
self.logger.info("Generating waveform from mixed audio")
waveform_processor = AudioWaveformProcessor(
audio_path=transcript.audio_mp3_filename,
waveform_path=transcript.audio_waveform_filename,
on_waveform=self.on_waveform,
)
waveform_processor.set_pipeline(self.empty_pipeline)
await waveform_processor.flush()
self.logger.info("Waveform generated successfully")
except Exception as e:
self.logger.error(
"Waveform generation failed", error=str(e), exc_info=True
)
# Transcribe PADDED tracks - timestamps will be automatically correct!
speaker_transcripts: list[TranscriptType] = []
for idx, padded_url in enumerate(padded_track_urls):
if not padded_url:
continue
try:
# Transcribe the PADDED track
t = await self.transcribe_file(padded_url, transcript.source_language)
except Exception as e:
self.logger.error(
"Transcription via default backend failed, trying local whisper",
track_idx=idx,
url=padded_url,
error=str(e),
)
try:
fallback = FileTranscriptAutoProcessor(name="whisper")
result = None
async def capture_result(r):
nonlocal result
result = r
fallback.on(capture_result)
await fallback.push(
FileTranscriptInput(
audio_url=padded_url, language=transcript.source_language
)
)
await fallback.flush()
if not result:
raise Exception("No transcript captured in fallback")
t = result
except Exception as e2:
self.logger.error(
"Skipping track - transcription failed after fallback",
track_idx=idx,
url=padded_url,
error=str(e2),
)
continue
if not t.words:
continue
# NO OFFSET ADJUSTMENT NEEDED!
# Timestamps are already correct because we transcribed padded tracks
# Just set speaker ID
for w in t.words:
w.speaker = idx
speaker_transcripts.append(t)
self.logger.info(
f"Track {idx} transcribed successfully with {len(t.words)} words",
track_idx=idx,
)
if not speaker_transcripts:
raise Exception("No valid track transcriptions")
# Merge all words and sort by timestamp
merged_words = []
for t in speaker_transcripts:
merged_words.extend(t.words)
merged_words.sort(
key=lambda w: w.start if hasattr(w, "start") and w.start is not None else 0
)
merged_transcript = TranscriptType(words=merged_words, translation=None)
# Emit TRANSCRIPT event through the shared handler (persists and broadcasts)
await self.on_transcript(merged_transcript)
topics = await self.detect_topics(merged_transcript, transcript.target_language)
await asyncio.gather(
self.generate_title(topics),
self.generate_summaries(topics),
return_exceptions=False,
)
await self.set_status(transcript.id, "ended")
async def transcribe_file(self, audio_url: str, language: str) -> TranscriptType:
processor = FileTranscriptAutoProcessor()
input_data = FileTranscriptInput(audio_url=audio_url, language=language)
result: TranscriptType | None = None
async def capture_result(transcript):
nonlocal result
result = transcript
processor.on(capture_result)
await processor.push(input_data)
await processor.flush()
if not result:
raise ValueError("No transcript captured")
return result
async def detect_topics(
self, transcript: TranscriptType, target_language: str
) -> list[TitleSummary]:
chunk_size = 300
topics: list[TitleSummary] = []
async def on_topic(topic: TitleSummary):
topics.append(topic)
return await self.on_topic(topic)
topic_detector = TranscriptTopicDetectorProcessor(callback=on_topic)
topic_detector.set_pipeline(self.empty_pipeline)
for i in range(0, len(transcript.words), chunk_size):
chunk_words = transcript.words[i : i + chunk_size]
if not chunk_words:
continue
chunk_transcript = TranscriptType(
words=chunk_words, translation=transcript.translation
)
await topic_detector.push(chunk_transcript)
await topic_detector.flush()
return topics
async def generate_title(self, topics: list[TitleSummary]):
if not topics:
self.logger.warning("No topics for title generation")
return
processor = TranscriptFinalTitleProcessor(callback=self.on_title)
processor.set_pipeline(self.empty_pipeline)
for topic in topics:
await processor.push(topic)
await processor.flush()
async def generate_summaries(self, topics: list[TitleSummary]):
if not topics:
self.logger.warning("No topics for summary generation")
return
transcript = await self.get_transcript()
processor = TranscriptFinalSummaryProcessor(
transcript=transcript,
callback=self.on_long_summary,
on_short_summary=self.on_short_summary,
)
processor.set_pipeline(self.empty_pipeline)
for topic in topics:
await processor.push(topic)
await processor.flush()
@shared_task
@asynctask
async def task_pipeline_multitrack_process(
*, transcript_id: str, bucket_name: str, track_keys: list[str]
):
pipeline = PipelineMainMultitrack(transcript_id=transcript_id)
try:
await pipeline.set_status(transcript_id, "processing")
await pipeline.process(bucket_name, track_keys)
except Exception:
await pipeline.set_status(transcript_id, "error")
raise
post_chain = chain(
task_cleanup_consent.si(transcript_id=transcript_id),
task_pipeline_post_to_zulip.si(transcript_id=transcript_id),
task_send_webhook_if_needed.si(transcript_id=transcript_id),
)
post_chain.delay()

View File

@@ -0,0 +1,629 @@
import asyncio
import io
from fractions import Fraction
import av
import boto3
import structlog
from av.audio.resampler import AudioResampler
from celery import chain, shared_task
from reflector.asynctask import asynctask
from reflector.db.transcripts import (
TranscriptStatus,
TranscriptText,
transcripts_controller,
)
from reflector.logger import logger
from reflector.pipelines.main_file_pipeline import task_send_webhook_if_needed
from reflector.pipelines.main_live_pipeline import (
PipelineMainBase,
task_cleanup_consent,
task_pipeline_post_to_zulip,
)
from reflector.processors import (
AudioFileWriterProcessor,
TranscriptFinalSummaryProcessor,
TranscriptFinalTitleProcessor,
TranscriptTopicDetectorProcessor,
)
from reflector.processors.file_transcript import FileTranscriptInput
from reflector.processors.file_transcript_auto import FileTranscriptAutoProcessor
from reflector.processors.types import TitleSummary
from reflector.processors.types import (
Transcript as TranscriptType,
)
from reflector.settings import settings
from reflector.storage import get_transcripts_storage
class EmptyPipeline:
def __init__(self, logger: structlog.BoundLogger):
self.logger = logger
def get_pref(self, k, d=None):
return d
async def emit(self, event):
pass
class PipelineMainMultitrack(PipelineMainBase):
"""Process multiple participant tracks for a transcript without mixing audio."""
def __init__(self, transcript_id: str):
super().__init__(transcript_id=transcript_id)
self.logger = logger.bind(transcript_id=self.transcript_id)
self.empty_pipeline = EmptyPipeline(logger=self.logger)
async def pad_track_for_transcription(
self,
track_data: bytes,
track_idx: int,
storage,
) -> tuple[bytes, str]:
"""
Pad a single track with silence based on stream metadata start_time.
This ensures Whisper timestamps will be relative to recording start.
Returns: (padded_data, storage_url)
"""
if not track_data:
return b"", ""
transcript = await self.get_transcript()
# Get stream metadata start_time using PyAV
container = av.open(io.BytesIO(track_data))
try:
audio_stream = container.streams.audio[0]
# Extract start_time from stream metadata
if (
audio_stream.start_time is not None
and audio_stream.time_base is not None
):
start_time_seconds = float(
audio_stream.start_time * audio_stream.time_base
)
else:
start_time_seconds = 0.0
sample_rate = audio_stream.sample_rate
codec_name = audio_stream.codec.name
finally:
container.close()
self.logger.info(
f"Track {track_idx} stream metadata: start_time={start_time_seconds:.3f}s, sample_rate={sample_rate}",
track_idx=track_idx,
)
# If no padding needed, use original
if start_time_seconds <= 0:
storage_path = (
f"file_pipeline/{transcript.id}/tracks/original_track_{track_idx}.webm"
)
await storage.put_file(storage_path, track_data)
url = await storage.get_file_url(storage_path)
return track_data, url
# Create PyAV filter graph for padding
graph = av.filter.Graph()
# Input buffer
in_args = (
f"time_base=1/{sample_rate}:"
f"sample_rate={sample_rate}:"
f"sample_fmt=s16:"
f"channel_layout=stereo"
)
input_buffer = graph.add("abuffer", args=in_args, name="in")
# Add delay filter for padding
delay_ms = int(start_time_seconds * 1000)
delay_filter = graph.add(
"adelay", args=f"delays={delay_ms}|{delay_ms}:all=1", name="delay"
)
# Output sink
sink = graph.add("abuffersink", name="out")
# Link filters
input_buffer.link_to(delay_filter)
delay_filter.link_to(sink)
graph.configure()
# Process audio through filter
output_bytes = io.BytesIO()
output_container = av.open(output_bytes, "w", format="webm")
output_stream = output_container.add_stream("libopus", rate=sample_rate)
output_stream.channels = 2
# Reopen input for processing
input_container = av.open(io.BytesIO(track_data))
resampler = AudioResampler(format="s16", layout="stereo", rate=sample_rate)
try:
# Process frames
for frame in input_container.decode(audio=0):
# Resample to match filter requirements
resampled_frames = resampler.resample(frame)
for resampled_frame in resampled_frames:
resampled_frame.pts = frame.pts
resampled_frame.time_base = Fraction(1, sample_rate)
input_buffer.push(resampled_frame)
# Pull from filter and encode
while True:
try:
out_frame = sink.pull()
out_frame.pts = out_frame.pts if out_frame.pts else 0
out_frame.time_base = Fraction(1, sample_rate)
for packet in output_stream.encode(out_frame):
output_container.mux(packet)
except av.BlockingIOError:
break
# Flush
input_buffer.push(None)
while True:
try:
out_frame = sink.pull()
for packet in output_stream.encode(out_frame):
output_container.mux(packet)
except (av.BlockingIOError, av.EOFError):
break
# Flush encoder
for packet in output_stream.encode(None):
output_container.mux(packet)
finally:
input_container.close()
output_container.close()
padded_data = output_bytes.getvalue()
# Store padded track
storage_path = (
f"file_pipeline/{transcript.id}/tracks/padded_track_{track_idx}.webm"
)
await storage.put_file(storage_path, padded_data)
padded_url = await storage.get_file_url(storage_path)
self.logger.info(
f"Padded track {track_idx} with {start_time_seconds:.3f}s offset, stored at {storage_path}",
track_idx=track_idx,
delay_ms=delay_ms,
padded_url=padded_url,
)
return padded_data, padded_url
async def mixdown_tracks(
self,
track_datas: list[bytes],
writer: AudioFileWriterProcessor,
offsets_seconds: list[float] | None = None,
) -> None:
"""
Minimal multi-track mixdown using a PyAV filter graph (amix), no resampling.
"""
# Discover target sample rate from first decodable frame
target_sample_rate: int | None = None
for data in track_datas:
if not data:
continue
try:
container = av.open(io.BytesIO(data))
try:
for frame in container.decode(audio=0):
target_sample_rate = frame.sample_rate
break
finally:
container.close()
except Exception:
continue
if target_sample_rate:
break
if not target_sample_rate:
self.logger.warning("Mixdown skipped - no decodable audio frames found")
return
# Build PyAV filter graph:
# N abuffer (s32/stereo)
# -> optional adelay per input (for alignment)
# -> amix (s32)
# -> aformat(s16)
# -> sink
graph = av.filter.Graph()
inputs = []
valid_track_datas = [d for d in track_datas if d]
# Align offsets list with the filtered inputs (skip empties)
input_offsets_seconds = None
if offsets_seconds is not None:
input_offsets_seconds = [
offsets_seconds[i] for i, d in enumerate(track_datas) if d
]
for idx, data in enumerate(valid_track_datas):
args = (
f"time_base=1/{target_sample_rate}:"
f"sample_rate={target_sample_rate}:"
f"sample_fmt=s32:"
f"channel_layout=stereo"
)
in_ctx = graph.add("abuffer", args=args, name=f"in{idx}")
inputs.append(in_ctx)
if not inputs:
self.logger.warning("Mixdown skipped - no valid inputs for graph")
return
mixer = graph.add("amix", args=f"inputs={len(inputs)}:normalize=0", name="mix")
fmt = graph.add(
"aformat",
args=(
f"sample_fmts=s32:channel_layouts=stereo:sample_rates={target_sample_rate}"
),
name="fmt",
)
sink = graph.add("abuffersink", name="out")
# Optional per-input delay before mixing
delays_ms: list[int] = []
if input_offsets_seconds is not None:
base = min(input_offsets_seconds) if input_offsets_seconds else 0.0
delays_ms = [
max(0, int(round((o - base) * 1000))) for o in input_offsets_seconds
]
else:
delays_ms = [0 for _ in inputs]
for idx, in_ctx in enumerate(inputs):
delay_ms = delays_ms[idx] if idx < len(delays_ms) else 0
if delay_ms > 0:
# adelay requires one value per channel; use same for stereo
adelay = graph.add(
"adelay",
args=f"delays={delay_ms}|{delay_ms}:all=1",
name=f"delay{idx}",
)
in_ctx.link_to(adelay)
adelay.link_to(mixer, 0, idx)
else:
in_ctx.link_to(mixer, 0, idx)
mixer.link_to(fmt)
fmt.link_to(sink)
graph.configure()
# Open containers for decoding
containers = []
for i, d in enumerate(valid_track_datas):
try:
c = av.open(io.BytesIO(d))
containers.append(c)
except Exception as e:
self.logger.warning(
"Mixdown: failed to open container", input=i, error=str(e)
)
containers.append(None)
# Filter out Nones for decoders
containers = [c for c in containers if c is not None]
decoders = [c.decode(audio=0) for c in containers]
active = [True] * len(decoders)
# Per-input resamplers to enforce s32/stereo at the same rate (no resample of rate)
resamplers = [
AudioResampler(format="s32", layout="stereo", rate=target_sample_rate)
for _ in decoders
]
try:
# Round-robin feed frames into graph, pull mixed frames as they become available
while any(active):
for i, (dec, is_active) in enumerate(zip(decoders, active)):
if not is_active:
continue
try:
frame = next(dec)
except StopIteration:
active[i] = False
continue
# Enforce same sample rate; convert format/layout to s16/stereo (no resample)
if frame.sample_rate != target_sample_rate:
# Skip frames with differing 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)
# Drain available mixed frames
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
await writer.push(mixed)
# Signal EOF to inputs and drain remaining
for in_ctx in inputs:
in_ctx.push(None)
while True:
try:
mixed = sink.pull()
except Exception:
break
mixed.sample_rate = target_sample_rate
mixed.time_base = Fraction(1, target_sample_rate)
await writer.push(mixed)
finally:
for c in containers:
c.close()
async def set_status(self, transcript_id: str, status: TranscriptStatus):
async with self.lock_transaction():
return await transcripts_controller.set_status(transcript_id, status)
async def process(self, bucket_name: str, track_keys: list[str]):
transcript = await self.get_transcript()
s3 = boto3.client(
"s3",
region_name=settings.RECORDING_STORAGE_AWS_REGION,
aws_access_key_id=settings.RECORDING_STORAGE_AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.RECORDING_STORAGE_AWS_SECRET_ACCESS_KEY,
)
storage = get_transcripts_storage()
# Pre-download bytes for all tracks for mixing and transcription
track_datas: list[bytes] = []
for key in track_keys:
try:
obj = s3.get_object(Bucket=bucket_name, Key=key)
track_datas.append(obj["Body"].read())
except Exception as e:
self.logger.warning(
"Skipping track - cannot read S3 object", key=key, error=str(e)
)
track_datas.append(b"")
# REMOVED: Filename offset extraction - not needed anymore!
# We use stream metadata start_time for padding instead
# Get stream metadata start_times for mixing (still useful for mixdown)
stream_start_times: list[float] = []
for data in track_datas:
if not data:
stream_start_times.append(0.0)
continue
container = av.open(io.BytesIO(data))
try:
audio_stream = container.streams.audio[0]
if (
audio_stream.start_time is not None
and audio_stream.time_base is not None
):
start_time = float(audio_stream.start_time * audio_stream.time_base)
else:
start_time = 0.0
stream_start_times.append(start_time)
finally:
container.close()
# Mixdown all available tracks into transcript.audio_mp3_filename, using stream metadata offsets
try:
mp3_writer = AudioFileWriterProcessor(
path=str(transcript.audio_mp3_filename)
)
await self.mixdown_tracks(track_datas, mp3_writer, stream_start_times)
await mp3_writer.flush()
except Exception as e:
self.logger.error("Mixdown failed", error=str(e))
# PAD TRACKS BEFORE TRANSCRIPTION - THIS IS THE KEY FIX!
padded_track_urls: list[str] = []
for idx, data in enumerate(track_datas):
if not data:
padded_track_urls.append("")
continue
_, padded_url = await self.pad_track_for_transcription(data, idx, storage)
padded_track_urls.append(padded_url)
self.logger.info(f"Padded track {idx} for transcription: {padded_url}")
# Transcribe PADDED tracks - timestamps will be automatically correct!
speaker_transcripts: list[TranscriptType] = []
for idx, padded_url in enumerate(padded_track_urls):
if not padded_url:
continue
try:
# Transcribe the PADDED track
t = await self.transcribe_file(padded_url, transcript.source_language)
except Exception as e:
self.logger.error(
"Transcription via default backend failed, trying local whisper",
track_idx=idx,
url=padded_url,
error=str(e),
)
try:
fallback = FileTranscriptAutoProcessor(name="whisper")
result = None
async def capture_result(r):
nonlocal result
result = r
fallback.on(capture_result)
await fallback.push(
FileTranscriptInput(
audio_url=padded_url, language=transcript.source_language
)
)
await fallback.flush()
if not result:
raise Exception("No transcript captured in fallback")
t = result
except Exception as e2:
self.logger.error(
"Skipping track - transcription failed after fallback",
track_idx=idx,
url=padded_url,
error=str(e2),
)
continue
if not t.words:
continue
# NO OFFSET ADJUSTMENT NEEDED!
# Timestamps are already correct because we transcribed padded tracks
# Just set speaker ID
for w in t.words:
w.speaker = idx
speaker_transcripts.append(t)
self.logger.info(
f"Track {idx} transcribed successfully with {len(t.words)} words",
track_idx=idx,
)
if not speaker_transcripts:
raise Exception("No valid track transcriptions")
# Merge all words and sort by timestamp
merged_words = []
for t in speaker_transcripts:
merged_words.extend(t.words)
merged_words.sort(
key=lambda w: w.start if hasattr(w, "start") and w.start is not None else 0
)
merged_transcript = TranscriptType(words=merged_words, translation=None)
await transcripts_controller.append_event(
transcript,
event="TRANSCRIPT",
data=TranscriptText(
text=merged_transcript.text, translation=merged_transcript.translation
),
)
topics = await self.detect_topics(merged_transcript, transcript.target_language)
await asyncio.gather(
self.generate_title(topics),
self.generate_summaries(topics),
return_exceptions=False,
)
await self.set_status(transcript.id, "ended")
async def transcribe_file(self, audio_url: str, language: str) -> TranscriptType:
processor = FileTranscriptAutoProcessor()
input_data = FileTranscriptInput(audio_url=audio_url, language=language)
result: TranscriptType | None = None
async def capture_result(transcript):
nonlocal result
result = transcript
processor.on(capture_result)
await processor.push(input_data)
await processor.flush()
if not result:
raise ValueError("No transcript captured")
return result
async def detect_topics(
self, transcript: TranscriptType, target_language: str
) -> list[TitleSummary]:
chunk_size = 300
topics: list[TitleSummary] = []
async def on_topic(topic: TitleSummary):
topics.append(topic)
return await self.on_topic(topic)
topic_detector = TranscriptTopicDetectorProcessor(callback=on_topic)
topic_detector.set_pipeline(self.empty_pipeline)
for i in range(0, len(transcript.words), chunk_size):
chunk_words = transcript.words[i : i + chunk_size]
if not chunk_words:
continue
chunk_transcript = TranscriptType(
words=chunk_words, translation=transcript.translation
)
await topic_detector.push(chunk_transcript)
await topic_detector.flush()
return topics
async def generate_title(self, topics: list[TitleSummary]):
if not topics:
self.logger.warning("No topics for title generation")
return
processor = TranscriptFinalTitleProcessor(callback=self.on_title)
processor.set_pipeline(self.empty_pipeline)
for topic in topics:
await processor.push(topic)
await processor.flush()
async def generate_summaries(self, topics: list[TitleSummary]):
if not topics:
self.logger.warning("No topics for summary generation")
return
transcript = await self.get_transcript()
processor = TranscriptFinalSummaryProcessor(
transcript=transcript,
callback=self.on_long_summary,
on_short_summary=self.on_short_summary,
)
processor.set_pipeline(self.empty_pipeline)
for topic in topics:
await processor.push(topic)
await processor.flush()
@shared_task
@asynctask
async def task_pipeline_multitrack_process(
*, transcript_id: str, bucket_name: str, track_keys: list[str]
):
pipeline = PipelineMainMultitrack(transcript_id=transcript_id)
try:
await pipeline.set_status(transcript_id, "processing")
await pipeline.process(bucket_name, track_keys)
except Exception:
await pipeline.set_status(transcript_id, "error")
raise
post_chain = chain(
task_cleanup_consent.si(transcript_id=transcript_id),
task_pipeline_post_to_zulip.si(transcript_id=transcript_id),
task_send_webhook_if_needed.si(transcript_id=transcript_id),
)
post_chain.delay()

View File

@@ -0,0 +1,9 @@
"""Platform type definitions.
This module exists solely to define the Platform literal type without any imports,
preventing circular import issues when used across the codebase.
"""
from typing import Literal
Platform = Literal["whereby", "daily"]

View File

@@ -1,6 +1,6 @@
from textwrap import dedent
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from reflector.llm import LLM
from reflector.processors.base import Processor
@@ -34,8 +34,14 @@ TOPIC_PROMPT = dedent(
class TopicResponse(BaseModel):
"""Structured response for topic detection"""
title: str = Field(description="A descriptive title for the topic being discussed")
summary: str = Field(description="A concise 1-2 sentence summary of the discussion")
model_config = ConfigDict(populate_by_name=True)
title: str = Field(
description="A descriptive title for the topic being discussed", alias="Title"
)
summary: str = Field(
description="A concise 1-2 sentence summary of the discussion", alias="Summary"
)
class TranscriptTopicDetectorProcessor(Processor):

View File

@@ -1,6 +1,7 @@
from pydantic.types import PositiveInt
from pydantic_settings import BaseSettings, SettingsConfigDict
from reflector.platform_types import Platform
from reflector.utils.string import NonEmptyString
@@ -129,6 +130,19 @@ class Settings(BaseSettings):
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None
SQS_POLLING_TIMEOUT_SECONDS: int = 60
# Daily.co integration
DAILY_API_KEY: str | None = None
DAILY_WEBHOOK_SECRET: str | None = None
DAILY_SUBDOMAIN: str | None = None
AWS_DAILY_S3_BUCKET: str | None = None
AWS_DAILY_S3_REGION: str = "us-west-2"
AWS_DAILY_ROLE_ARN: str | None = None
# Platform Migration Feature Flags
DAILY_MIGRATION_ENABLED: bool = False
DAILY_MIGRATION_ROOM_IDS: list[str] = []
DEFAULT_VIDEO_PLATFORM: Platform = "whereby"
# Zulip integration
ZULIP_REALM: str | None = None
ZULIP_API_KEY: str | None = None

View File

@@ -0,0 +1,18 @@
# Video Platform Abstraction Layer
"""
This module provides an abstraction layer for different video conferencing platforms.
It allows seamless switching between providers (Whereby, Daily.co, etc.) without
changing the core application logic.
"""
from .base import VideoPlatformClient
from .models import MeetingData, VideoPlatformConfig
from .registry import get_platform_client, register_platform
__all__ = [
"VideoPlatformClient",
"VideoPlatformConfig",
"MeetingData",
"get_platform_client",
"register_platform",
]

View File

@@ -0,0 +1,60 @@
from abc import ABC, abstractmethod
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, Optional
from reflector.platform_types import Platform
from .models import MeetingData, VideoPlatformConfig
if TYPE_CHECKING:
from reflector.db.rooms import Room
class VideoPlatformClient(ABC):
"""Abstract base class for video platform integrations."""
PLATFORM_NAME: Platform
def __init__(self, config: VideoPlatformConfig):
self.config = config
@abstractmethod
async def create_meeting(
self, room_name_prefix: str, end_date: datetime, room: "Room"
) -> MeetingData:
"""Create a new meeting room."""
pass
@abstractmethod
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
"""Get session information for a room."""
pass
@abstractmethod
async def delete_room(self, room_name: str) -> bool:
"""Delete a room. Returns True if successful."""
pass
@abstractmethod
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
"""Upload a logo to the room. Returns True if successful."""
pass
@abstractmethod
def verify_webhook_signature(
self, body: bytes, signature: str, timestamp: Optional[str] = None
) -> bool:
"""Verify webhook signature for security."""
pass
def format_recording_config(self, room: "Room") -> Dict[str, Any]:
"""Format recording configuration for the platform.
Can be overridden by specific implementations."""
if room.recording_type == "cloud" and self.config.s3_bucket:
return {
"type": room.recording_type,
"bucket": self.config.s3_bucket,
"region": self.config.s3_region,
"trigger": room.recording_trigger,
}
return {"type": room.recording_type}

View File

@@ -0,0 +1,178 @@
import hmac
from datetime import datetime
from hashlib import sha256
from http import HTTPStatus
from typing import Any, Dict, Optional
import httpx
from reflector.db.rooms import Room
from reflector.platform_types import Platform
from .base import VideoPlatformClient
from .models import MeetingData, RecordingType, VideoPlatformConfig
class DailyClient(VideoPlatformClient):
PLATFORM_NAME: Platform = "daily"
TIMEOUT = 10
BASE_URL = "https://api.daily.co/v1"
TIMESTAMP_FORMAT = "%Y%m%d%H%M%S"
RECORDING_NONE: RecordingType = "none"
RECORDING_CLOUD: RecordingType = "cloud"
def __init__(self, config: VideoPlatformConfig):
super().__init__(config)
self.headers = {
"Authorization": f"Bearer {config.api_key}",
"Content-Type": "application/json",
}
async def create_meeting(
self, room_name_prefix: str, end_date: datetime, room: Room
) -> MeetingData:
"""Create a Daily.co room."""
timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
if room_name_prefix:
room_name = f"{room_name_prefix}-{timestamp}"
else:
room_name = f"room-{timestamp}"
data = {
"name": room_name,
"privacy": "private" if room.is_locked else "public",
"properties": {
"enable_recording": "raw-tracks"
if room.recording_type != self.RECORDING_NONE
else False,
"enable_chat": True,
"enable_screenshare": True,
"start_video_off": False,
"start_audio_off": False,
"exp": int(end_date.timestamp()),
},
}
# Configure S3 bucket for recordings
# NOTE: Not checking room.recording_type - figure out later if conditional needed
assert self.config.s3_bucket, "S3 bucket must be configured"
data["properties"]["recordings_bucket"] = {
"bucket_name": self.config.s3_bucket,
"bucket_region": self.config.s3_region,
"assume_role_arn": self.config.aws_role_arn,
"allow_api_access": True,
}
from reflector.logger import logger
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/rooms",
headers=self.headers,
json=data,
timeout=self.TIMEOUT,
)
if response.status_code >= 400:
logger.error(
"Daily.co API error",
status_code=response.status_code,
response_body=response.text,
request_data=data,
)
response.raise_for_status()
result = response.json()
# Format response to match our standard
room_url = result["url"]
return MeetingData(
meeting_id=result["id"],
room_name=result["name"],
room_url=room_url,
host_room_url=room_url,
platform=self.PLATFORM_NAME,
extra_data=result,
)
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
"""Get Daily.co room information."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.BASE_URL}/rooms/{room_name}",
headers=self.headers,
timeout=self.TIMEOUT,
)
response.raise_for_status()
return response.json()
async def get_room_presence(self, room_name: str) -> Dict[str, Any]:
"""Get real-time participant data - Daily.co specific feature."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.BASE_URL}/rooms/{room_name}/presence",
headers=self.headers,
timeout=self.TIMEOUT,
)
response.raise_for_status()
return response.json()
async def delete_room(self, room_name: str) -> bool:
"""Delete a Daily.co room."""
async with httpx.AsyncClient() as client:
response = await client.delete(
f"{self.BASE_URL}/rooms/{room_name}",
headers=self.headers,
timeout=self.TIMEOUT,
)
# Daily.co returns 200 for success, 404 if room doesn't exist
return response.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
"""Daily.co doesn't support custom logos per room - this is a no-op."""
return True
def verify_webhook_signature(
self, body: bytes, signature: str, timestamp: Optional[str] = None
) -> bool:
"""Verify Daily.co webhook signature.
Daily.co uses:
- X-Webhook-Signature header
- X-Webhook-Timestamp header
- Signature format: HMAC-SHA256(base64_decode(secret), timestamp + '.' + body)
- Result is base64 encoded
"""
if not signature or not timestamp:
return False
try:
import base64
secret_bytes = base64.b64decode(self.config.webhook_secret)
signed_content = timestamp.encode() + b"." + body
expected = hmac.new(secret_bytes, signed_content, sha256).digest()
expected_b64 = base64.b64encode(expected).decode()
return hmac.compare_digest(expected_b64, signature)
except Exception:
return False
async def create_meeting_token(self, room_name: str, enable_recording: bool) -> str:
"""Create meeting token for auto-recording."""
data = {"properties": {"room_name": room_name}}
if enable_recording:
data["properties"]["start_cloud_recording"] = True
data["properties"]["enable_recording_ui"] = False
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/meeting-tokens",
headers=self.headers,
json=data,
timeout=self.TIMEOUT,
)
response.raise_for_status()
return response.json()["token"]

View File

@@ -0,0 +1,80 @@
"""Factory for creating video platform clients based on configuration."""
from typing import Optional
from reflector.settings import settings
from .base import Platform, VideoPlatformClient, VideoPlatformConfig
from .registry import get_platform_client
def get_platform_config(platform: Platform) -> VideoPlatformConfig:
"""Get configuration for a specific platform."""
if platform == "whereby":
if not settings.WHEREBY_API_KEY:
raise ValueError(
"WHEREBY_API_KEY is required when platform='whereby'. "
"Set WHEREBY_API_KEY environment variable."
)
return VideoPlatformConfig(
api_key=settings.WHEREBY_API_KEY,
webhook_secret=settings.WHEREBY_WEBHOOK_SECRET or "",
api_url=settings.WHEREBY_API_URL,
s3_bucket=settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
s3_region=settings.RECORDING_STORAGE_AWS_REGION,
aws_access_key_id=settings.AWS_WHEREBY_ACCESS_KEY_ID,
aws_access_key_secret=settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
)
elif platform == "daily":
if not settings.DAILY_API_KEY:
raise ValueError(
"DAILY_API_KEY is required when platform='daily'. "
"Set DAILY_API_KEY environment variable."
)
if not settings.DAILY_SUBDOMAIN:
raise ValueError(
"DAILY_SUBDOMAIN is required when platform='daily'. "
"Set DAILY_SUBDOMAIN environment variable."
)
return VideoPlatformConfig(
api_key=settings.DAILY_API_KEY,
webhook_secret=settings.DAILY_WEBHOOK_SECRET or "",
subdomain=settings.DAILY_SUBDOMAIN,
s3_bucket=settings.AWS_DAILY_S3_BUCKET,
s3_region=settings.AWS_DAILY_S3_REGION,
aws_role_arn=settings.AWS_DAILY_ROLE_ARN,
)
else:
raise ValueError(f"Unknown platform: {platform}")
def create_platform_client(platform: Platform) -> VideoPlatformClient:
"""Create a video platform client instance."""
config = get_platform_config(platform)
return get_platform_client(platform, config)
def get_platform_for_room(
room_id: Optional[str] = None, room_platform: Optional[Platform] = None
) -> Platform:
"""Determine which platform to use for a room.
Priority order (highest to lowest):
1. DAILY_MIGRATION_ROOM_IDS - env var override for testing/migration
2. room_platform - database persisted platform choice
3. DEFAULT_VIDEO_PLATFORM - env var fallback
"""
# If Daily migration is disabled, always use Whereby
if not settings.DAILY_MIGRATION_ENABLED:
return "whereby"
# Highest priority: If room is in migration list, use Daily (env var override)
if room_id and room_id in settings.DAILY_MIGRATION_ROOM_IDS:
return "daily"
# Second priority: Use room's persisted platform from database
if room_platform:
return room_platform
# Fallback: Use default platform from env var
return settings.DEFAULT_VIDEO_PLATFORM

View File

@@ -0,0 +1,49 @@
"""Video platform data models.
Standard data models used across all video platform implementations.
"""
from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, Field
from reflector.platform_types import Platform
RecordingType = Literal["none", "local", "cloud"]
class MeetingData(BaseModel):
"""Standardized meeting data returned by all providers."""
platform: Platform
meeting_id: str = Field(description="Platform-specific meeting identifier")
room_url: str = Field(description="URL for participants to join")
host_room_url: str = Field(description="URL for hosts (may be same as room_url)")
room_name: str = Field(description="Human-readable room name")
extra_data: Dict[str, Any] = Field(default_factory=dict)
class Config:
json_schema_extra = {
"example": {
"platform": "whereby",
"meeting_id": "12345678",
"room_url": "https://subdomain.whereby.com/room-20251008120000",
"host_room_url": "https://subdomain.whereby.com/room-20251008120000?roomKey=abc123",
"room_name": "room-20251008120000",
}
}
class VideoPlatformConfig(BaseModel):
"""Platform-agnostic configuration model."""
api_key: str
webhook_secret: str
api_url: Optional[str] = None
subdomain: Optional[str] = None # Whereby/Daily subdomain
s3_bucket: Optional[str] = None
s3_region: Optional[str] = None
# Whereby uses access keys, Daily uses IAM role
aws_access_key_id: Optional[str] = None
aws_access_key_secret: Optional[str] = None
aws_role_arn: Optional[str] = None

View File

@@ -0,0 +1,39 @@
from typing import Dict, Type
from .base import Platform, VideoPlatformClient, VideoPlatformConfig
# Registry of available video platforms
_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {}
def register_platform(name: Platform, client_class: Type[VideoPlatformClient]):
"""Register a video platform implementation."""
_PLATFORMS[name] = client_class
def get_platform_client(
platform: Platform, config: VideoPlatformConfig
) -> VideoPlatformClient:
"""Get a video platform client instance."""
if platform not in _PLATFORMS:
raise ValueError(f"Unknown video platform: {platform}")
client_class = _PLATFORMS[platform]
return client_class(config)
def get_available_platforms() -> list[Platform]:
"""Get list of available platform names."""
return list(_PLATFORMS.keys())
# Auto-register built-in platforms
def _register_builtin_platforms():
from .daily import DailyClient # noqa: PLC0415
from .whereby import WherebyClient # noqa: PLC0415
register_platform("whereby", WherebyClient)
register_platform("daily", DailyClient)
_register_builtin_platforms()

View File

@@ -0,0 +1,140 @@
import hmac
import json
import re
import time
from datetime import datetime
from hashlib import sha256
from typing import Any, Dict, Optional
import httpx
from reflector.db.rooms import Room
from .base import MeetingData, Platform, VideoPlatformClient, VideoPlatformConfig
class WherebyClient(VideoPlatformClient):
"""Whereby video platform implementation."""
PLATFORM_NAME: Platform = "whereby"
TIMEOUT = 10 # seconds
MAX_ELAPSED_TIME = 60 * 1000 # 1 minute in milliseconds
def __init__(self, config: VideoPlatformConfig):
super().__init__(config)
self.headers = {
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"Bearer {config.api_key}",
}
async def create_meeting(
self, room_name_prefix: str, end_date: datetime, room: Room
) -> MeetingData:
"""Create a Whereby meeting."""
data = {
"isLocked": room.is_locked,
"roomNamePrefix": room_name_prefix,
"roomNamePattern": "uuid",
"roomMode": room.room_mode,
"endDate": end_date.isoformat(),
"fields": ["hostRoomUrl"],
}
# Add recording configuration if cloud recording is enabled
if room.recording_type == "cloud":
data["recording"] = {
"type": room.recording_type,
"destination": {
"provider": "s3",
"bucket": self.config.s3_bucket,
"accessKeyId": self.config.aws_access_key_id,
"accessKeySecret": self.config.aws_access_key_secret,
"fileFormat": "mp4",
},
"startTrigger": room.recording_trigger,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.config.api_url}/meetings",
headers=self.headers,
json=data,
timeout=self.TIMEOUT,
)
response.raise_for_status()
result = response.json()
return MeetingData(
meeting_id=result["meetingId"],
room_name=result["roomName"],
room_url=result["roomUrl"],
host_room_url=result["hostRoomUrl"],
platform=self.PLATFORM_NAME,
extra_data=result,
)
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
"""Get Whereby room session information."""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.config.api_url}/insights/room-sessions?roomName={room_name}",
headers=self.headers,
timeout=self.TIMEOUT,
)
response.raise_for_status()
return response.json()
async def delete_room(self, room_name: str) -> bool:
"""Whereby doesn't support room deletion - meetings expire automatically."""
return True
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
"""Upload logo to Whereby room."""
async with httpx.AsyncClient() as client:
with open(logo_path, "rb") as f:
response = await client.put(
f"{self.config.api_url}/rooms/{room_name}/theme/logo",
headers={
"Authorization": f"Bearer {self.config.api_key}",
},
timeout=self.TIMEOUT,
files={"image": f},
)
response.raise_for_status()
return True
def verify_webhook_signature(
self, body: bytes, signature: str, timestamp: Optional[str] = None
) -> bool:
"""Verify Whereby webhook signature."""
if not signature:
return False
matches = re.match(r"t=(.*),v1=(.*)", signature)
if not matches:
return False
ts, sig = matches.groups()
# Check timestamp to prevent replay attacks
current_time = int(time.time() * 1000)
diff_time = current_time - int(ts) * 1000
if diff_time >= self.MAX_ELAPSED_TIME:
return False
# Verify signature
body_dict = json.loads(body)
signed_payload = f"{ts}.{json.dumps(body_dict, separators=(',', ':'))}"
hmac_obj = hmac.new(
self.config.webhook_secret.encode("utf-8"),
signed_payload.encode("utf-8"),
sha256,
)
expected_signature = hmac_obj.hexdigest()
try:
return hmac.compare_digest(
expected_signature.encode("utf-8"), sig.encode("utf-8")
)
except Exception:
return False

View File

@@ -0,0 +1,235 @@
"""Daily.co webhook handler endpoint."""
import json
from typing import Any, Dict, Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from reflector.db.meetings import meetings_controller
from reflector.logger import logger
from reflector.settings import settings
from reflector.video_platforms.factory import create_platform_client
from reflector.worker.process import process_multitrack_recording
router = APIRouter()
class DailyTrack(BaseModel):
"""Daily.co recording track (audio or video file)."""
type: Literal["audio", "video"]
s3Key: str
size: int
class DailyWebhookEvent(BaseModel):
"""Daily webhook event structure."""
version: str
type: str
id: str
payload: Dict[str, Any]
event_ts: float
def _extract_room_name(event: DailyWebhookEvent) -> str | None:
"""Extract room name from Daily event payload.
Daily.co API inconsistency:
- participant.* events use "room" field
- recording.* events use "room_name" field
"""
return event.payload.get("room_name") or event.payload.get("room")
@router.post("/webhook")
async def webhook(request: Request):
"""Handle Daily webhook events.
Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook
state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py
"""
body = await request.body()
signature = request.headers.get("X-Webhook-Signature", "")
timestamp = request.headers.get("X-Webhook-Timestamp", "")
client = create_platform_client("daily")
# TEMPORARY: Bypass signature check for testing
# TODO: Remove this after testing is complete
BYPASS_FOR_TESTING = True
if not BYPASS_FOR_TESTING:
if not client.verify_webhook_signature(body, signature, timestamp):
logger.warning(
"Invalid webhook signature",
signature=signature,
timestamp=timestamp,
has_body=bool(body),
)
raise HTTPException(status_code=401, detail="Invalid webhook signature")
# Parse the JSON body
try:
body_json = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=422, detail="Invalid JSON")
# Handle Daily's test event during webhook creation
if body_json.get("test") == "test":
logger.info("Received Daily webhook test event")
return {"status": "ok"}
# Parse as actual event
try:
event = DailyWebhookEvent(**body_json)
except Exception as e:
logger.error("Failed to parse webhook event", error=str(e), body=body.decode())
raise HTTPException(status_code=422, detail="Invalid event format")
# Handle participant events
if event.type == "participant.joined":
await _handle_participant_joined(event)
elif event.type == "participant.left":
await _handle_participant_left(event)
elif event.type == "recording.started":
await _handle_recording_started(event)
elif event.type == "recording.ready-to-download":
await _handle_recording_ready(event)
elif event.type == "recording.error":
await _handle_recording_error(event)
return {"status": "ok"}
async def _handle_participant_joined(event: DailyWebhookEvent):
"""Handle participant joined event."""
room_name = _extract_room_name(event)
if not room_name:
logger.warning("participant.joined: no room in payload", payload=event.payload)
return
meeting = await meetings_controller.get_by_room_name(room_name)
if meeting:
await meetings_controller.increment_num_clients(meeting.id)
logger.info(
"Participant joined",
meeting_id=meeting.id,
room_name=room_name,
recording_type=meeting.recording_type,
recording_trigger=meeting.recording_trigger,
)
else:
logger.warning("participant.joined: meeting not found", room_name=room_name)
async def _handle_participant_left(event: DailyWebhookEvent):
"""Handle participant left event."""
room_name = _extract_room_name(event)
if not room_name:
return
meeting = await meetings_controller.get_by_room_name(room_name)
if meeting:
await meetings_controller.decrement_num_clients(meeting.id)
async def _handle_recording_started(event: DailyWebhookEvent):
"""Handle recording started event."""
room_name = _extract_room_name(event)
if not room_name:
logger.warning(
"recording.started: no room_name in payload", payload=event.payload
)
return
meeting = await meetings_controller.get_by_room_name(room_name)
if meeting:
logger.info(
"Recording started",
meeting_id=meeting.id,
room_name=room_name,
recording_id=event.payload.get("recording_id"),
platform="daily",
)
else:
logger.warning("recording.started: meeting not found", room_name=room_name)
async def _handle_recording_ready(event: DailyWebhookEvent):
"""Handle recording ready for download event.
Daily.co webhook payload for raw-tracks recordings:
{
"recording_id": "...",
"room_name": "test2-20251009192341",
"tracks": [
{"type": "audio", "s3Key": "monadical/test2-.../uuid-cam-audio-123.webm", "size": 400000},
{"type": "video", "s3Key": "monadical/test2-.../uuid-cam-video-456.webm", "size": 30000000}
]
}
"""
room_name = _extract_room_name(event)
recording_id = event.payload.get("recording_id")
tracks_raw = event.payload.get("tracks", [])
if not room_name or not tracks_raw:
logger.warning(
"recording.ready-to-download: missing room_name or tracks",
room_name=room_name,
has_tracks=bool(tracks_raw),
payload=event.payload,
)
return
# Validate tracks structure
try:
tracks = [DailyTrack(**t) for t in tracks_raw]
except Exception as e:
logger.error(
"recording.ready-to-download: invalid tracks structure",
error=str(e),
tracks=tracks_raw,
)
return
logger.info(
"Recording ready for download",
room_name=room_name,
recording_id=recording_id,
num_tracks=len(tracks),
platform="daily",
)
bucket_name = settings.AWS_DAILY_S3_BUCKET
if not bucket_name:
logger.error(
"AWS_DAILY_S3_BUCKET not configured; cannot process Daily recording"
)
return
track_keys = [t.s3Key for t in tracks if t.type == "audio"]
process_multitrack_recording.delay(
bucket_name=bucket_name,
room_name=room_name,
recording_id=recording_id,
track_keys=track_keys,
)
async def _handle_recording_error(event: DailyWebhookEvent):
"""Handle recording error event."""
room_name = _extract_room_name(event)
error = event.payload.get("error", "Unknown error")
if room_name:
meeting = await meetings_controller.get_by_room_name(room_name)
if meeting:
logger.error(
"Recording error",
meeting_id=meeting.id,
room_name=room_name,
error=error,
platform="daily",
)

View File

@@ -17,7 +17,11 @@ from reflector.db.rooms import rooms_controller
from reflector.redis_cache import RedisAsyncLock
from reflector.services.ics_sync import ics_sync_service
from reflector.settings import settings
from reflector.whereby import create_meeting, upload_logo
from reflector.video_platforms.base import Platform
from reflector.video_platforms.factory import (
create_platform_client,
get_platform_for_room,
)
from reflector.worker.webhook import test_webhook
logger = logging.getLogger(__name__)
@@ -41,6 +45,7 @@ class Room(BaseModel):
ics_enabled: bool = False
ics_last_sync: Optional[datetime] = None
ics_last_etag: Optional[str] = None
platform: Platform = "whereby"
class RoomDetails(Room):
@@ -68,6 +73,7 @@ class Meeting(BaseModel):
is_active: bool = True
calendar_event_id: str | None = None
calendar_metadata: dict[str, Any] | None = None
platform: Platform = "whereby"
class CreateRoom(BaseModel):
@@ -85,6 +91,7 @@ class CreateRoom(BaseModel):
ics_url: Optional[str] = None
ics_fetch_interval: int = 300
ics_enabled: bool = False
platform: Optional[Platform] = None
class UpdateRoom(BaseModel):
@@ -102,6 +109,7 @@ class UpdateRoom(BaseModel):
ics_url: Optional[str] = None
ics_fetch_interval: Optional[int] = None
ics_enabled: Optional[bool] = None
platform: Optional[Platform] = None
class CreateRoomMeeting(BaseModel):
@@ -182,13 +190,18 @@ async def rooms_list(
user_id = user["sub"] if user else None
return await apaginate(
paginated = await apaginate(
get_database(),
await rooms_controller.get_all(
user_id=user_id, order_by="-created_at", return_query=True
),
)
for room in paginated.items:
room.platform = get_platform_for_room(room.id, room.platform)
return paginated
@router.get("/rooms/{room_id}", response_model=RoomDetails)
async def rooms_get(
@@ -199,6 +212,9 @@ async def rooms_get(
room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
if not room.is_shared and (user_id is None or room.user_id != user_id):
raise HTTPException(status_code=403, detail="Room access denied")
room.platform = get_platform_for_room(room.id, room.platform)
return room
@@ -212,26 +228,25 @@ async def rooms_get_by_name(
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# Convert to RoomDetails format (add webhook fields if user is owner)
room_dict = room.__dict__.copy()
if user_id == room.user_id:
# User is owner, include webhook details if available
room_dict["webhook_url"] = getattr(room, "webhook_url", None)
room_dict["webhook_secret"] = getattr(room, "webhook_secret", None)
else:
# Non-owner, hide webhook details
room_dict["webhook_url"] = None
room_dict["webhook_secret"] = None
room_dict["platform"] = get_platform_for_room(room.id, room.platform)
return RoomDetails(**room_dict)
@router.post("/rooms", response_model=Room)
async def rooms_create(
room: CreateRoom,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"] if user else None
user_id = user["sub"]
return await rooms_controller.add(
name=room.name,
@@ -249,6 +264,7 @@ async def rooms_create(
ics_url=room.ics_url,
ics_fetch_interval=room.ics_fetch_interval,
ics_enabled=room.ics_enabled,
platform=room.platform,
)
@@ -256,26 +272,31 @@ async def rooms_create(
async def rooms_update(
room_id: str,
info: UpdateRoom,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"] if user else None
user_id = user["sub"]
room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
if room.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
values = info.dict(exclude_unset=True)
await rooms_controller.update(room, values)
room.platform = get_platform_for_room(room.id, room.platform)
return room
@router.delete("/rooms/{room_id}", response_model=DeletionStatus)
async def rooms_delete(
room_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"] if user else None
room = await rooms_controller.get_by_id(room_id, user_id=user_id)
user_id = user["sub"]
room = await rooms_controller.get_by_id(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
if room.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
await rooms_controller.remove_by_id(room.id, user_id=user_id)
return DeletionStatus(status="ok")
@@ -309,20 +330,27 @@ async def rooms_create_meeting(
if meeting is None:
end_date = current_time + timedelta(hours=8)
whereby_meeting = await create_meeting("", end_date=end_date, room=room)
# Determine which platform to use
platform = get_platform_for_room(room.id, room.platform)
client = create_platform_client(platform)
await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
# Create meeting via platform abstraction
meeting_data = await client.create_meeting(
room.name, end_date=end_date, room=room
)
# Upload logo if supported by platform
await client.upload_logo(meeting_data.room_name, "./images/logo.png")
meeting = await meetings_controller.create(
id=whereby_meeting["meetingId"],
room_name=whereby_meeting["roomName"],
room_url=whereby_meeting["roomUrl"],
host_room_url=whereby_meeting["hostRoomUrl"],
start_date=parse_datetime_with_timezone(
whereby_meeting["startDate"]
),
end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]),
id=meeting_data.meeting_id,
room_name=meeting_data.room_name,
room_url=meeting_data.room_url,
host_room_url=meeting_data.host_room_url,
start_date=current_time,
end_date=end_date,
room=room,
platform=platform,
)
except LockError:
logger.warning("Failed to acquire lock for room %s within timeout", room_name)
@@ -330,6 +358,18 @@ async def rooms_create_meeting(
status_code=503, detail="Meeting creation in progress, please try again"
)
meeting.platform = get_platform_for_room(room.id, room.platform)
if meeting.platform == "daily" and room.recording_trigger != "none":
client = create_platform_client(meeting.platform)
token = await client.create_meeting_token(
meeting.room_name, enable_recording=True
)
meeting = meeting.model_copy()
meeting.room_url += f"?t={token}"
if meeting.host_room_url:
meeting.host_room_url += f"?t={token}"
if user_id != room.user_id:
meeting.host_room_url = ""
@@ -339,16 +379,16 @@ async def rooms_create_meeting(
@router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult)
async def rooms_test_webhook(
room_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
"""Test webhook configuration by sending a sample payload."""
user_id = user["sub"] if user else None
user_id = user["sub"]
room = await rooms_controller.get_by_id(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
if user_id and room.user_id != user_id:
if room.user_id != user_id:
raise HTTPException(
status_code=403, detail="Not authorized to test this room's webhook"
)
@@ -484,7 +524,10 @@ async def rooms_list_active_meetings(
room=room, current_time=current_time
)
# Hide host URLs from non-owners
effective_platform = get_platform_for_room(room.id, room.platform)
for meeting in meetings:
meeting.platform = effective_platform
if user_id != room.user_id:
for meeting in meetings:
meeting.host_room_url = ""
@@ -514,6 +557,8 @@ async def rooms_get_meeting(
status_code=403, detail="Meeting does not belong to this room"
)
meeting.platform = get_platform_for_room(room.id, room.platform)
if user_id != room.user_id and not room.is_shared:
meeting.host_room_url = ""
@@ -549,7 +594,8 @@ async def rooms_join_meeting(
if meeting.end_date <= current_time:
raise HTTPException(status_code=400, detail="Meeting has ended")
# Hide host URL from non-owners
meeting.platform = get_platform_for_room(room.id, room.platform)
if user_id != room.user_id:
meeting.host_room_url = ""

View File

@@ -9,8 +9,6 @@ from pydantic import BaseModel, Field, constr, field_serializer
import reflector.auth as auth
from reflector.db import get_database
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.search import (
DEFAULT_SEARCH_LIMIT,
SearchLimit,
@@ -34,6 +32,7 @@ from reflector.db.transcripts import (
from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.processors.types import Word
from reflector.settings import settings
from reflector.ws_manager import get_ws_manager
from reflector.zulip import (
InvalidMessageError,
get_zulip_message,
@@ -213,7 +212,7 @@ async def transcripts_create(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
user_id = user["sub"] if user else None
return await transcripts_controller.add(
transcript = await transcripts_controller.add(
info.name,
source_kind=info.source_kind or SourceKind.LIVE,
source_language=info.source_language,
@@ -221,6 +220,14 @@ async def transcripts_create(
user_id=user_id,
)
if user_id:
await get_ws_manager().send_json(
room_id=f"user:{user_id}",
message={"event": "TRANSCRIPT_CREATED", "data": {"id": transcript.id}},
)
return transcript
# ==============================================================
# Single transcript
@@ -344,12 +351,14 @@ async def transcript_get(
async def transcript_update(
transcript_id: str,
info: UpdateTranscript,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
values = info.dict(exclude_unset=True)
updated_transcript = await transcripts_controller.update(transcript, values)
return updated_transcript
@@ -358,20 +367,20 @@ async def transcript_update(
@router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus)
async def transcript_delete(
transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
room = await rooms_controller.get_by_id(meeting.room_id)
if room.is_shared:
user_id = None
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.remove_by_id(transcript.id, user_id=user_id)
await get_ws_manager().send_json(
room_id=f"user:{user_id}",
message={"event": "TRANSCRIPT_DELETED", "data": {"id": transcript.id}},
)
return DeletionStatus(status="ok")
@@ -443,15 +452,16 @@ async def transcript_post_to_zulip(
stream: str,
topic: str,
include_topics: bool,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
content = get_zulip_message(transcript, include_topics)
message_updated = False

View File

@@ -56,12 +56,14 @@ async def transcript_get_participants(
async def transcript_add_participant(
transcript_id: str,
participant: CreateParticipant,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> Participant:
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
# ensure the speaker is unique
if participant.speaker is not None and transcript.participants is not None:
@@ -101,12 +103,14 @@ async def transcript_update_participant(
transcript_id: str,
participant_id: str,
participant: UpdateParticipant,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> Participant:
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
# ensure the speaker is unique
for p in transcript.participants:
@@ -138,11 +142,13 @@ async def transcript_update_participant(
async def transcript_delete_participant(
transcript_id: str,
participant_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> DeletionStatus:
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.delete_participant(transcript, participant_id)
return DeletionStatus(status="ok")

View File

@@ -35,12 +35,14 @@ class SpeakerMerge(BaseModel):
async def transcript_assign_speaker(
transcript_id: str,
assignment: SpeakerAssignment,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> SpeakerAssignmentStatus:
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
@@ -113,12 +115,14 @@ async def transcript_assign_speaker(
async def transcript_merge_speaker(
transcript_id: str,
merge: SpeakerMerge,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> SpeakerAssignmentStatus:
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.user_id is not None and transcript.user_id != user_id:
raise HTTPException(status_code=403, detail="Not authorized")
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")

View File

@@ -4,8 +4,11 @@ Transcripts websocket API
"""
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
import reflector.auth as auth
from reflector.db.transcripts import transcripts_controller
from reflector.ws_manager import get_ws_manager
@@ -21,10 +24,12 @@ async def transcript_get_websocket_events(transcript_id: str):
async def transcript_events_websocket(
transcript_id: str,
websocket: WebSocket,
# user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Optional[auth.UserInfo] = Depends(auth.current_user_optional),
):
# user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id(transcript_id)
user_id = user["sub"] if user else None
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")

View File

@@ -0,0 +1,53 @@
from typing import Optional
from fastapi import APIRouter, WebSocket
from reflector.auth.auth_jwt import JWTAuth # type: ignore
from reflector.ws_manager import get_ws_manager
router = APIRouter()
# Close code for unauthorized WebSocket connections
UNAUTHORISED = 4401
@router.websocket("/events")
async def user_events_websocket(websocket: WebSocket):
# Browser can't send Authorization header for WS; use subprotocol: ["bearer", token]
raw_subprotocol = websocket.headers.get("sec-websocket-protocol") or ""
parts = [p.strip() for p in raw_subprotocol.split(",") if p.strip()]
token: Optional[str] = None
negotiated_subprotocol: Optional[str] = None
if len(parts) >= 2 and parts[0].lower() == "bearer":
negotiated_subprotocol = "bearer"
token = parts[1]
user_id: Optional[str] = None
if not token:
await websocket.close(code=UNAUTHORISED)
return
try:
payload = JWTAuth().verify_token(token)
user_id = payload.get("sub")
except Exception:
await websocket.close(code=UNAUTHORISED)
return
if not user_id:
await websocket.close(code=UNAUTHORISED)
return
room_id = f"user:{user_id}"
ws_manager = get_ws_manager()
await ws_manager.add_user_to_room(
room_id, websocket, subprotocol=negotiated_subprotocol
)
try:
while True:
await websocket.receive()
finally:
if room_id:
await ws_manager.remove_user_from_room(room_id, websocket)

View File

@@ -5,7 +5,6 @@ Deletes old anonymous transcripts and their associated meetings/recordings.
Transcripts are the main entry point - any associated data is also removed.
"""
import asyncio
from datetime import datetime, timedelta, timezone
from typing import TypedDict
@@ -152,5 +151,5 @@ async def cleanup_old_public_data(
retry_kwargs={"max_retries": 3, "countdown": 300},
)
@asynctask
def cleanup_old_public_data_task(days: int | None = None):
asyncio.run(cleanup_old_public_data(days=days))
async def cleanup_old_public_data_task(days: int | None = None):
await cleanup_old_public_data(days=days)

View File

@@ -0,0 +1,179 @@
"""Stub data for Daily.co testing - Fish conversation"""
import re
from typing import Any
from reflector.processors.types import Word
from reflector.utils import generate_uuid4
# Constants for stub data generation
MIN_WORD_DURATION = 0.3 # Base duration per word in seconds
WORD_LENGTH_MULTIPLIER = 0.05 # Additional duration per character
NUM_STUB_TOPICS = 3 # Number of topics to generate
# The fish argument text - 2 speakers arguing about eating fish
FISH_TEXT = """Fish for dinner are nothing wrong with you? There's nothing wrong with me. Wrong with you? Would you shut up? There's nothing wrong with me. I'm just trying to. There's nothing wrong with me. I'm trying to eat a fish. Wrong with you trying to eat a fish and it falls off the plate. Would you shut up? You're bothering me. More than a fish is bothering me. Would you shut up and leave me alone? What's your problem? I'm just trying to eat a fish is wrong with you. I'm only trying to eat a fish. Would you shut up? Wrong with you. There's nothing wrong with me. There's nothing wrong with me. Wrong with you. There's nothing wrong with me. Wrong with you. There's nothing wrong with me. Would you shut up and let me eat my fish? Wrong with you. Shut up! What is wrong with you? Would you just shut up? What's your problem? Would you shut up with you? What is wrong with you? Wrong with me? I'm just trying to get my attention. Did you shut up? You're bothering me. Would you shut up? You're beginning to bug me. What's your problem? Just trying to eat my fish. Stay on the plate. Would you shut up? Just trying to eat my fish.
I'm gonna hit you with my problem. You're worse than this fish. You're more of a problem than a fish. What's your problem? Would you shut up? Would you shut your mouth? I want to eat my fish. Shut up! I can't even think. What's your problem? Trying to eat my fish is wrong with you. I don't have a problem. What is wrong with you? I have a problem. What's your problem? I don't have a problem. Can't you hear me with you? Can't you hear me? I don't have a problem. I want to eat my fish. Your problem? Just want to eat. What is wrong with you? Shut up! What is wrong with you? You just shut up! What's your problem? What is wrong with you anyway? What is wrong with you? I won't stay on the plate. You shut up! What is wrong with you? Would you just shut up? Let me eat my fish. What's your problem? Shut up and leave me alone! I can't even think. Wrong with you. I don't have a problem. Problem? I don't have a problem. Wrong with you. I don't have a problem with you. That's your problem. Don't have a problem? I want to eat my fish.
What is wrong with you? What's your problem? Problem? I just want to eat my fish. Wrong with you. What's wrong with you? I don't have a problem. You shut up! What's wrong with you? Just shut up! What's wrong with you? Shut up! What is wrong with you? I'm trying to eat a fish. I'm trying to eat a fish and it falls off the plate. Would you shut up? What is wrong with you? Would you shut up? Is wrong with you? Would you just shut up? What is wrong with you? Would you just shut? Is wrong with you? What's your problem? You just shut. What is wrong with you? Trying to eat my fish. Would you be quiet? What's your problem? Would you just shut up? Eat my fish. I can't even eat it. Don't stay on the plate. What's your problem? Would you shut up? What is wrong with you? What is wrong with you? Would you just shut up? What's your problem? What is wrong with you? I'm gonna hit you with my fish if you don't shut up. What's your problem? Would you shut up? What's wrong with you? What is wrong? Shut up! What's your problem?"""
def parse_fish_text() -> list[Word]:
"""Parse fish text into words with timestamps and speakers.
Returns list of Word objects with text, start/end timestamps, and speaker ID.
Speaker assignment heuristic:
- Speaker 0 (eating fish): "fish", "eat", "trying", "problem", "I"
- Speaker 1 (annoying): "wrong with you", "shut up", "What's your problem"
"""
# Split into sentences (rough)
sentences = re.split(r"([.!?])", FISH_TEXT)
# Reconstruct sentences with punctuation
full_sentences = []
for i in range(0, len(sentences) - 1, 2):
if sentences[i].strip():
full_sentences.append(
sentences[i].strip()
+ (sentences[i + 1] if i + 1 < len(sentences) else "")
)
words: list[Word] = []
current_time = 0.0
for sentence in full_sentences:
if not sentence.strip():
continue
# TODO: Delete this heuristic-based speaker detection when real diarization is implemented.
# This overly complex pattern matching is only for stub test data.
# Real implementation should use actual speaker diarization from audio processing.
# Determine speaker based on content
sentence_lower = sentence.lower()
# Speaker 1 patterns (annoying person)
if any(
p in sentence_lower
for p in [
"wrong with you",
"shut up",
"what's your problem",
"what is wrong",
"would you shut",
"you shut",
]
):
speaker = 1
# Speaker 0 patterns (trying to eat)
elif any(
p in sentence_lower
for p in [
"i'm trying",
"i'm just",
"i want to eat",
"eat my fish",
"trying to eat",
"nothing wrong with me",
"i don't have a problem",
"just trying",
"leave me alone",
"can't even",
"i'm gonna hit",
]
):
speaker = 0
# Default: alternate or use context
else:
# For short phrases, guess based on keywords
if "fish" in sentence_lower and "eat" in sentence_lower:
speaker = 0
elif "problem" in sentence_lower and "your" not in sentence_lower:
speaker = 0
else:
speaker = 1
# Split sentence into words
sentence_words = sentence.split()
for word in sentence_words:
word_duration = MIN_WORD_DURATION + (len(word) * WORD_LENGTH_MULTIPLIER)
words.append(
Word(
text=word + " ", # Add space
start=current_time,
end=current_time + word_duration,
speaker=speaker,
)
)
current_time += word_duration
return words
def generate_fake_topics(words: list[Word]) -> list[dict[str, Any]]:
"""Generate fake topics from words.
Splits into equal topics based on word count.
Returns list of topic dicts for database storage.
"""
if not words:
return []
chunk_size = len(words) // NUM_STUB_TOPICS
topics: list[dict[str, Any]] = []
for i in range(NUM_STUB_TOPICS):
start_idx = i * chunk_size
end_idx = (i + 1) * chunk_size if i < NUM_STUB_TOPICS - 1 else len(words)
if start_idx >= len(words):
break
chunk_words = words[start_idx:end_idx]
topic = {
"id": generate_uuid4(),
"title": f"Fish Argument Part {i+1}",
"summary": f"Argument about eating fish continues (part {i+1})",
"timestamp": chunk_words[0].start,
"duration": chunk_words[-1].end - chunk_words[0].start,
"transcript": "".join(w.text for w in chunk_words),
"words": [w.model_dump() for w in chunk_words],
}
topics.append(topic)
return topics
def generate_fake_participants() -> list[dict[str, Any]]:
"""Generate fake participants for stub transcript."""
return [
{"id": generate_uuid4(), "speaker": 0, "name": "Fish Eater"},
{"id": generate_uuid4(), "speaker": 1, "name": "Annoying Person"},
]
def get_stub_transcript_data() -> dict[str, Any]:
"""Get complete stub transcript data for Daily.co testing.
Returns dict with topics, participants, title, summaries, duration.
All data is fake/predetermined for testing webhook flow without GPU processing.
"""
words = parse_fish_text()
topics = generate_fake_topics(words)
participants = generate_fake_participants()
return {
"topics": topics,
"participants": participants,
"title": "The Great Fish Eating Argument",
"short_summary": "Two people argue about eating fish",
"long_summary": "An extended argument between someone trying to eat fish and another person who won't stop asking what's wrong. The fish keeps falling off the plate.",
"duration": words[-1].end if words else 0.0,
}

View File

@@ -10,7 +10,7 @@ from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.redis_cache import RedisAsyncLock
from reflector.services.ics_sync import SyncStatus, ics_sync_service
from reflector.whereby import create_meeting, upload_logo
from reflector.video_platforms.factory import create_platform_client
logger = structlog.wrap_logger(get_task_logger(__name__))
@@ -104,20 +104,24 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room
try:
end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION)
whereby_meeting = await create_meeting(
# Use platform abstraction to create meeting
platform = room.platform
client = create_platform_client(platform)
meeting_data = await client.create_meeting(
"",
end_date=end_date,
room=room,
)
await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
await client.upload_logo(meeting_data.room_name, "./images/logo.png")
meeting = await meetings_controller.create(
id=whereby_meeting["meetingId"],
room_name=whereby_meeting["roomName"],
room_url=whereby_meeting["roomUrl"],
host_room_url=whereby_meeting["hostRoomUrl"],
start_date=datetime.fromisoformat(whereby_meeting["startDate"]),
end_date=datetime.fromisoformat(whereby_meeting["endDate"]),
id=meeting_data.meeting_id,
room_name=meeting_data.room_name,
room_url=meeting_data.room_url,
host_room_url=meeting_data.host_room_url,
start_date=event.start_time,
end_date=end_date,
room=room,
calendar_event_id=event.id,
calendar_metadata={
@@ -125,6 +129,7 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room
"description": event.description,
"attendees": event.attendees,
},
platform=platform,
)
logger.info(

View File

@@ -1,5 +1,6 @@
import json
import os
import re
from datetime import datetime, timezone
from urllib.parse import unquote
@@ -17,9 +18,13 @@ from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
from reflector.pipelines.main_live_pipeline import asynctask
from reflector.pipelines.main_multitrack_pipeline import (
task_pipeline_multitrack_process,
)
from reflector.redis_cache import get_redis_client
from reflector.settings import settings
from reflector.whereby import get_room_sessions
from reflector.worker.daily_stub_data import get_stub_transcript_data
logger = structlog.wrap_logger(get_task_logger(__name__))
@@ -146,6 +151,96 @@ async def process_recording(bucket_name: str, object_key: str):
task_pipeline_file_process.delay(transcript_id=transcript.id)
@shared_task
@asynctask
async def process_multitrack_recording(
bucket_name: str,
room_name: str,
recording_id: str,
track_keys: list[str],
):
logger.info(
"Processing multitrack recording",
bucket=bucket_name,
room_name=room_name,
recording_id=recording_id,
provided_keys=len(track_keys),
)
if not track_keys:
logger.warning("No audio track keys provided")
return
recorded_at = datetime.now(timezone.utc)
try:
if track_keys:
folder = os.path.basename(os.path.dirname(track_keys[0]))
ts_match = re.search(r"(\d{14})$", folder)
if ts_match:
ts = ts_match.group(1)
recorded_at = datetime.strptime(ts, "%Y%m%d%H%M%S").replace(
tzinfo=timezone.utc
)
except Exception:
logger.warning("Could not parse recorded_at from keys, using now()")
room_name = room_name.split("-", 1)[0]
room = await rooms_controller.get_by_name(room_name)
if not room:
raise Exception(f"Room not found: {room_name}")
meeting = await meetings_controller.create(
id=recording_id,
room_name=room_name,
room_url=room.name,
host_room_url=room.name,
start_date=recorded_at,
end_date=recorded_at,
room=room,
platform=room.platform,
)
recording = await recordings_controller.get_by_id(recording_id)
if not recording:
object_key_dir = os.path.dirname(track_keys[0]) if track_keys else ""
recording = await recordings_controller.create(
Recording(
id=recording_id,
bucket_name=bucket_name,
object_key=object_key_dir,
recorded_at=recorded_at,
meeting_id=meeting.id,
)
)
transcript = await transcripts_controller.get_by_recording_id(recording.id)
if transcript:
await transcripts_controller.update(
transcript,
{
"topics": [],
},
)
else:
transcript = await transcripts_controller.add(
"",
source_kind=SourceKind.ROOM,
source_language="en",
target_language="en",
user_id=room.user_id,
recording_id=recording.id,
share_mode="public",
meeting_id=meeting.id,
room_id=room.id,
)
task_pipeline_multitrack_process.delay(
transcript_id=transcript.id,
bucket_name=bucket_name,
track_keys=track_keys,
)
@shared_task
@asynctask
async def process_meetings():
@@ -213,7 +308,6 @@ async def process_meetings():
should_deactivate = True
logger_.info(
"Meeting deactivated - scheduled time ended with no participants",
meeting.id,
)
else:
logger_.debug("Meeting not yet started, keep it")
@@ -224,8 +318,8 @@ async def process_meetings():
processed_count += 1
except Exception as e:
logger_.error(f"Error processing meeting", exc_info=True)
except Exception:
logger_.error("Error processing meeting", exc_info=True)
finally:
try:
lock.release()
@@ -233,12 +327,245 @@ async def process_meetings():
pass # Lock already released or expired
logger.info(
f"Processed meetings finished",
"Processed meetings finished",
processed_count=processed_count,
skipped_count=skipped_count,
)
async def convert_audio_and_waveform(transcript) -> None:
"""Convert WebM to MP3 and generate waveform for Daily.co recordings.
This bypasses the full file pipeline which would overwrite stub data.
"""
try:
logger.info(
"Converting audio to MP3 and generating waveform",
transcript_id=transcript.id,
)
# Import processors we need
from reflector.processors import AudioFileWriterProcessor
from reflector.processors.audio_waveform_processor import AudioWaveformProcessor
upload_path = transcript.data_path / "upload.webm"
mp3_path = transcript.audio_mp3_filename
# Convert WebM to MP3
mp3_writer = AudioFileWriterProcessor(path=mp3_path)
container = av.open(str(upload_path))
for frame in container.decode(audio=0):
await mp3_writer.push(frame)
await mp3_writer.flush()
container.close()
logger.info(
"Converted WebM to MP3",
transcript_id=transcript.id,
mp3_size=mp3_path.stat().st_size,
)
# Generate waveform
waveform_processor = AudioWaveformProcessor(
audio_path=mp3_path,
waveform_path=transcript.audio_waveform_filename,
)
# Create minimal pipeline object for processor (matching EmptyPipeline from main_file_pipeline.py)
class MinimalPipeline:
def __init__(self, logger_instance):
self.logger = logger_instance
def get_pref(self, k, d=None):
return d
waveform_processor.set_pipeline(MinimalPipeline(logger))
await waveform_processor.flush()
logger.info(
"Generated waveform",
transcript_id=transcript.id,
waveform_path=transcript.audio_waveform_filename,
)
# Update transcript status to ended (successful)
await transcripts_controller.update(transcript, {"status": "ended"})
except Exception as e:
logger.error(
"Failed to convert audio or generate waveform",
transcript_id=transcript.id,
error=str(e),
)
# Keep status as uploaded even if conversion fails
pass
@shared_task
@asynctask
async def process_daily_recording(
meeting_id: str, recording_id: str, tracks: list[dict]
) -> None:
"""Stub processor for Daily.co recordings - writes fake transcription/diarization.
Handles webhook retries by checking if recording already exists.
Validates track structure before processing.
Args:
meeting_id: Meeting ID
recording_id: Recording ID from Daily.co webhook
tracks: List of track dicts from Daily.co webhook
[{type: 'audio'|'video', s3Key: str, size: int}, ...]
"""
logger.info(
"Processing Daily.co recording (STUB)",
meeting_id=meeting_id,
recording_id=recording_id,
num_tracks=len(tracks),
)
# Check if recording already exists (webhook retry case)
existing_recording = await recordings_controller.get_by_id(recording_id)
if existing_recording:
logger.warning(
"Recording already exists, skipping processing (likely webhook retry)",
recording_id=recording_id,
)
return
meeting = await meetings_controller.get_by_id(meeting_id)
if not meeting:
raise Exception(f"Meeting {meeting_id} not found")
room = await rooms_controller.get_by_id(meeting.room_id)
# Validate bucket configuration
if not settings.AWS_DAILY_S3_BUCKET:
raise ValueError("AWS_DAILY_S3_BUCKET not configured for Daily.co processing")
# Validate and parse tracks
# Import at runtime to avoid circular dependency (daily.py imports from process.py)
from reflector.views.daily import DailyTrack # noqa: PLC0415
try:
validated_tracks = [DailyTrack(**t) for t in tracks]
except Exception as e:
logger.error(
"Invalid track structure from Daily.co webhook",
error=str(e),
tracks=tracks,
)
raise ValueError(f"Invalid track structure: {e}")
# Find first audio track for Recording entity
audio_track = next((t for t in validated_tracks if t.type == "audio"), None)
if not audio_track:
raise Exception(f"No audio tracks found in {len(tracks)} tracks")
# Create Recording entry
recording = await recordings_controller.create(
Recording(
id=recording_id,
bucket_name=settings.AWS_DAILY_S3_BUCKET,
object_key=audio_track.s3Key,
recorded_at=datetime.now(timezone.utc),
meeting_id=meeting.id,
status="completed",
)
)
logger.info(
"Created recording",
recording_id=recording.id,
s3_key=audio_track.s3Key,
)
# Create Transcript entry
transcript = await transcripts_controller.add(
"",
source_kind=SourceKind.ROOM,
source_language="en",
target_language="en",
user_id=room.user_id,
recording_id=recording.id,
share_mode="public",
meeting_id=meeting.id,
room_id=room.id,
)
logger.info("Created transcript", transcript_id=transcript.id)
# Download audio file from Daily.co S3 for playback
upload_filename = transcript.data_path / "upload.webm"
upload_filename.parent.mkdir(parents=True, exist_ok=True)
s3 = boto3.client(
"s3",
region_name=settings.TRANSCRIPT_STORAGE_AWS_REGION,
aws_access_key_id=settings.TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY,
)
try:
logger.info(
"Downloading audio from Daily.co S3",
bucket=settings.AWS_DAILY_S3_BUCKET,
key=audio_track.s3Key,
)
with open(upload_filename, "wb") as f:
s3.download_fileobj(settings.AWS_DAILY_S3_BUCKET, audio_track.s3Key, f)
# Validate audio file
container = av.open(upload_filename.as_posix())
try:
if not len(container.streams.audio):
raise Exception("File has no audio stream")
finally:
container.close()
logger.info("Audio file downloaded and validated", file=str(upload_filename))
except Exception as e:
logger.error(
"Failed to download or validate audio file",
error=str(e),
bucket=settings.AWS_DAILY_S3_BUCKET,
key=audio_track.s3Key,
)
# Continue with stub data even if audio download fails
pass
# Generate fake data
stub_data = get_stub_transcript_data()
# Update transcript with fake data
await transcripts_controller.update(
transcript,
{
"topics": stub_data["topics"],
"participants": stub_data["participants"],
"title": stub_data["title"],
"short_summary": stub_data["short_summary"],
"long_summary": stub_data["long_summary"],
"duration": stub_data["duration"],
"status": "uploaded" if upload_filename.exists() else "ended",
},
)
logger.info(
"Daily.co recording processed (STUB)",
transcript_id=transcript.id,
duration=stub_data["duration"],
num_topics=len(stub_data["topics"]),
has_audio=upload_filename.exists(),
)
# Convert WebM to MP3 and generate waveform without full pipeline
# (full pipeline would overwrite our stub transcription data)
if upload_filename.exists():
await convert_audio_and_waveform(transcript)
@shared_task
@asynctask
async def reprocess_failed_recordings():

View File

@@ -11,6 +11,8 @@ import structlog
from celery import shared_task
from celery.utils.log import get_task_logger
from reflector.db.calendar_events import calendar_events_controller
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import transcripts_controller
from reflector.pipelines.main_live_pipeline import asynctask
@@ -84,6 +86,18 @@ async def send_transcript_webhook(
}
)
# Fetch meeting and calendar event if they exist
calendar_event = None
try:
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if meeting and meeting.calendar_event_id:
calendar_event = await calendar_events_controller.get_by_id(
meeting.calendar_event_id
)
except Exception as e:
logger.error("Error fetching meeting or calendar event", error=str(e))
# Build webhook payload
frontend_url = f"{settings.UI_BASE_URL}/transcripts/{transcript.id}"
participants = [
@@ -116,6 +130,33 @@ async def send_transcript_webhook(
},
}
# Always include calendar_event field, even if no event is present
payload_data["calendar_event"] = {}
# Add calendar event data if present
if calendar_event:
calendar_data = {
"id": calendar_event.id,
"ics_uid": calendar_event.ics_uid,
"title": calendar_event.title,
"start_time": calendar_event.start_time.isoformat()
if calendar_event.start_time
else None,
"end_time": calendar_event.end_time.isoformat()
if calendar_event.end_time
else None,
}
# Add optional fields only if they exist
if calendar_event.description:
calendar_data["description"] = calendar_event.description
if calendar_event.location:
calendar_data["location"] = calendar_event.location
if calendar_event.attendees:
calendar_data["attendees"] = calendar_event.attendees
payload_data["calendar_event"] = calendar_data
# Convert to JSON
payload_json = json.dumps(payload_data, separators=(",", ":"))
payload_bytes = payload_json.encode("utf-8")

View File

@@ -65,8 +65,13 @@ class WebsocketManager:
self.tasks: dict = {}
self.pubsub_client = pubsub_client
async def add_user_to_room(self, room_id: str, websocket: WebSocket) -> None:
await websocket.accept()
async def add_user_to_room(
self, room_id: str, websocket: WebSocket, subprotocol: str | None = None
) -> None:
if subprotocol:
await websocket.accept(subprotocol=subprotocol)
else:
await websocket.accept()
if room_id in self.rooms:
self.rooms[room_id].append(websocket)

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python
"""
Reprocess the Daily.co multitrack recording to fix audio mixdown
"""
import asyncio
from reflector.pipelines.main_multitrack_pipeline import (
task_pipeline_multitrack_process,
)
async def reprocess():
"""Process the multitrack recording with fixed mixdown"""
bucket_name = "reflector-dailyco-local"
track_keys = [
"monadical/daily-20251020193458/1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922",
"monadical/daily-20251020193458/1760988935484-a37c35e3-6f8e-4274-a482-e9d0f102a732-cam-audio-1760988943823",
]
# Create a new transcript with fixed mixdown
import uuid
from reflector.db import get_database
from reflector.db.transcripts import Transcript, transcripts
db = get_database()
await db.connect()
try:
transcript_id = str(uuid.uuid4())
transcript = Transcript(
id=transcript_id,
name="Daily Multitrack - With Audio Mixdown",
source_kind="file",
source_language="en",
target_language="en",
status="idle",
events=[],
title="",
)
query = transcripts.insert().values(**transcript.model_dump())
await db.execute(query)
print(f"Created transcript: {transcript_id}")
# Process with the fixed pipeline
await task_pipeline_multitrack_process(
transcript_id=transcript_id, bucket_name=bucket_name, track_keys=track_keys
)
print(
f"Processing complete! Check: http://localhost:3000/transcripts/{transcript_id}"
)
return transcript_id
finally:
await db.disconnect()
if __name__ == "__main__":
transcript_id = asyncio.run(reprocess())
print(f"\n✅ Reprocessing complete!")
print(f"📍 View at: http://localhost:3000/transcripts/{transcript_id}")

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Recreate Daily.co webhook (fixes circuit-breaker FAILED state)."""
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import httpx
from reflector.settings import settings
async def recreate_webhook(webhook_url: str):
"""Delete all webhooks and create new one."""
if not settings.DAILY_API_KEY:
print("Error: DAILY_API_KEY not set")
return 1
headers = {
"Authorization": f"Bearer {settings.DAILY_API_KEY}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient() as client:
# List existing webhooks
resp = await client.get("https://api.daily.co/v1/webhooks", headers=headers)
resp.raise_for_status()
webhooks = resp.json()
# Delete all existing webhooks
for wh in webhooks:
uuid = wh["uuid"]
print(f"Deleting webhook {uuid} (state: {wh['state']})")
await client.delete(
f"https://api.daily.co/v1/webhooks/{uuid}", headers=headers
)
# Create new webhook
webhook_data = {
"url": webhook_url,
"eventTypes": [
"participant.joined",
"participant.left",
"recording.started",
"recording.ready-to-download",
"recording.error",
],
"hmac": settings.DAILY_WEBHOOK_SECRET,
}
resp = await client.post(
"https://api.daily.co/v1/webhooks", headers=headers, json=webhook_data
)
resp.raise_for_status()
result = resp.json()
print(f"Created webhook {result['uuid']} (state: {result['state']})")
print(f"URL: {result['url']}")
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python recreate_daily_webhook.py <webhook_url>")
print(
"Example: python recreate_daily_webhook.py https://example.com/v1/daily/webhook"
)
sys.exit(1)
sys.exit(asyncio.run(recreate_webhook(sys.argv[1])))

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python
"""
Test script to trigger multitrack recording processing with ffmpeg padding fix
"""
import asyncio
from reflector.pipelines.main_multitrack_pipeline import PipelineMainMultitrack
async def test_processing():
"""Manually trigger multitrack processing for the test recording"""
# Initialize database connection
from reflector.db import get_database
db = get_database()
await db.connect()
try:
# The test recording with known speaker timeline
bucket_name = "monadical"
track_keys = [
"daily-20251020193458/1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922.webm",
"daily-20251020193458/1760988935484-a37c35e3-6f8e-4274-a482-e9d0f102a732-cam-audio-1760988943823.webm",
]
# Create a new transcript ID
import uuid
transcript_id = str(uuid.uuid4())
# Create transcript directly with SQL
from reflector.db.transcripts import (
Transcript,
transcripts,
transcripts_controller,
)
pipeline = PipelineMainMultitrack(transcript_id=transcript_id)
# Create transcript model
transcript = Transcript(
id=transcript_id,
name="FFMPEG Test - Daily Multitrack Recording",
source_kind="file",
source_language="en",
target_language="en",
status="idle",
events=[],
title="",
)
# Insert into database
query = transcripts.insert().values(**transcript.model_dump())
await db.execute(query)
print(f"Created transcript: {transcript_id}")
# Process the tracks using the pipeline
print(f"Processing multitrack recording with ffmpeg padding...")
print(f"Track 0: ...935922.webm (expected to start at ~2s)")
print(f"Track 1: ...943823.webm (expected to start at ~51s)")
try:
await pipeline.set_status(transcript_id, "processing")
await pipeline.process(bucket_name, track_keys)
print(f"Processing complete!")
except Exception as e:
await pipeline.set_status(transcript_id, "error")
print(f"Error during processing: {e}")
import traceback
traceback.print_exc()
raise
# Check the results
final_transcript = await transcripts_controller.get(transcript_id)
print(f"\nTranscript status: {final_transcript.status}")
print(f"Transcript title: {final_transcript.title}")
# Extract timeline from events
if final_transcript.events:
for event in final_transcript.events:
if event.get("event") == "TRANSCRIPT":
text = event.get("data", {}).get("text", "")
# Show first 500 chars to check if speakers are properly separated
print(f"\nTranscript text (first 500 chars):")
print(text[:500])
# Show last 500 chars too to see if second speaker is at the end
print(f"\nTranscript text (last 500 chars):")
print(text[-500:])
# Count words per speaker
words = text.split()
print(f"\nTotal words in transcript: {len(words)}")
# Check if text has proper speaker separation
# Expected: First ~45% from speaker 0, then ~35% from speaker 1, then ~20% from speaker 0
first_third = " ".join(words[: len(words) // 3])
middle_third = " ".join(
words[len(words) // 3 : 2 * len(words) // 3]
)
last_third = " ".join(words[2 * len(words) // 3 :])
print(f"\nFirst third preview: {first_third[:100]}...")
print(f"Middle third preview: {middle_third[:100]}...")
print(f"Last third preview: {last_third[:100]}...")
break
return transcript_id
finally:
await db.disconnect()
if __name__ == "__main__":
transcript_id = asyncio.run(test_processing())
print(f"\n✅ Test complete! Transcript ID: {transcript_id}")
print(f"\nExpected timeline:")
print(f" Speaker 0: ~2s to ~49s (first participant speaks)")
print(f" Speaker 1: ~51s to ~70s (second participant speaks)")
print(f" Speaker 0: ~73s to end (first participant speaks again)")
print(
f"\nIf the text shows proper chronological order (not interleaved), the fix worked!"
)

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python
"""
Test script to trigger multitrack recording processing with ffmpeg padding fix
This version loads tracks from local filesystem instead of S3
"""
import asyncio
import os
from reflector.pipelines.main_multitrack_pipeline import PipelineMainMultitrack
async def test_processing():
"""Manually trigger multitrack processing for the test recording"""
# Initialize database connection
from reflector.db import get_database
db = get_database()
await db.connect()
try:
# Create a new transcript ID
import uuid
transcript_id = str(uuid.uuid4())
# Create transcript directly with SQL
from reflector.db.transcripts import (
Transcript,
transcripts,
transcripts_controller,
)
pipeline = PipelineMainMultitrack(transcript_id=transcript_id)
# Create transcript model
transcript = Transcript(
id=transcript_id,
name="FFMPEG Test - Daily Multitrack Recording",
source_kind="file",
source_language="en",
target_language="en",
status="idle",
events=[],
title="",
)
# Insert into database
query = transcripts.insert().values(**transcript.model_dump())
await db.execute(query)
print(f"Created transcript: {transcript_id}")
# Read track files from local filesystem (in the container they'll be at /app/)
tracks_dir = "/app"
track_files = [
"1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922.webm",
"1760988935484-a37c35e3-6f8e-4274-a482-e9d0f102a732-cam-audio-1760988943823.webm",
]
# Read track data
track_datas = []
for track_file in track_files:
file_path = os.path.join(tracks_dir, track_file)
if os.path.exists(file_path):
with open(file_path, "rb") as f:
track_datas.append(f.read())
print(f"Loaded track: {track_file} ({len(track_datas[-1])} bytes)")
else:
print(f"Track file not found: {file_path}")
track_datas.append(b"")
# Process the tracks using the pipeline
print(f"\nProcessing multitrack recording with ffmpeg padding...")
print(f"Track 0: ...935922.webm (expected to start at ~2s)")
print(f"Track 1: ...943823.webm (expected to start at ~51s)")
# Call the process method directly with track data
# We'll need to mock S3 operations and directly work with the data
# Save tracks to temporary files and process them
try:
await pipeline.set_status(transcript_id, "processing")
# Create a mock bucket and keys setup
bucket_name = "test-bucket"
track_keys = ["track0.webm", "track1.webm"]
# Mock S3 client to return our local data
from unittest.mock import MagicMock, patch
mock_s3 = MagicMock()
def mock_get_object(Bucket, Key):
idx = 0 if "track0" in Key else 1
return {"Body": MagicMock(read=lambda: track_datas[idx])}
mock_s3.get_object = mock_get_object
# Patch boto3.client to return our mock
with patch("boto3.client", return_value=mock_s3):
await pipeline.process(bucket_name, track_keys)
print(f"Processing complete!")
except Exception as e:
await pipeline.set_status(transcript_id, "error")
print(f"Error during processing: {e}")
import traceback
traceback.print_exc()
raise
# Check the results
final_transcript = await transcripts_controller.get(transcript_id)
print(f"\nTranscript status: {final_transcript.status}")
print(f"Transcript title: {final_transcript.title}")
# Extract timeline from events
if final_transcript.events:
for event in final_transcript.events:
if event.get("event") == "TRANSCRIPT":
text = event.get("data", {}).get("text", "")
# Show first 500 chars to check if speakers are properly separated
print(f"\nTranscript text (first 500 chars):")
print(text[:500])
# Show last 500 chars too to see if second speaker is at the end
print(f"\nTranscript text (last 500 chars):")
print(text[-500:])
# Count words per speaker
words = text.split()
print(f"\nTotal words in transcript: {len(words)}")
# Check if text has proper speaker separation
# Expected: First ~45% from speaker 0, then ~35% from speaker 1, then ~20% from speaker 0
first_third = " ".join(words[: len(words) // 3])
middle_third = " ".join(
words[len(words) // 3 : 2 * len(words) // 3]
)
last_third = " ".join(words[2 * len(words) // 3 :])
print(f"\nFirst third preview: {first_third[:100]}...")
print(f"Middle third preview: {middle_third[:100]}...")
print(f"Last third preview: {last_third[:100]}...")
break
return transcript_id
finally:
await db.disconnect()
if __name__ == "__main__":
transcript_id = asyncio.run(test_processing())
print(f"\n✅ Test complete! Transcript ID: {transcript_id}")
print(f"\nExpected timeline:")
print(f" Speaker 0: ~2s to ~49s (first participant speaks)")
print(f" Speaker 1: ~51s to ~70s (second participant speaks)")
print(f" Speaker 0: ~73s to end (first participant speaks again)")
print(
f"\nIf the text shows proper chronological order (not interleaved), the fix worked!"
)

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python
"""
Test multitrack processing with correct S3 bucket configuration
"""
import asyncio
import uuid
from reflector.db import get_database
from reflector.db.transcripts import Transcript, transcripts
from reflector.pipelines.main_multitrack_pipeline import (
task_pipeline_multitrack_process,
)
async def create_and_process():
"""Create a new transcript and process with correct S3 bucket"""
# Correct S3 configuration
bucket_name = "reflector-dailyco-local"
track_keys = [
"monadical/daily-20251020193458/1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922",
"monadical/daily-20251020193458/1760988935484-a37c35e3-6f8e-4274-a482-e9d0f102a732-cam-audio-1760988943823",
]
# Create a new transcript
db = get_database()
await db.connect()
try:
transcript_id = str(uuid.uuid4())
transcript = Transcript(
id=transcript_id,
name="Daily Multitrack - Correct S3 Bucket Test",
source_kind="file",
source_language="en",
target_language="en",
status="idle",
events=[],
title="",
)
query = transcripts.insert().values(**transcript.model_dump())
await db.execute(query)
print(f"Created transcript: {transcript_id}")
# Trigger processing with Celery
result = task_pipeline_multitrack_process.delay(
transcript_id=transcript_id, bucket_name=bucket_name, track_keys=track_keys
)
print(f"Task ID: {result.id}")
print(
f"Processing started! Check: http://localhost:3000/transcripts/{transcript_id}"
)
print(f"API Status: http://localhost:1250/v1/transcripts/{transcript_id}")
return transcript_id
finally:
await db.disconnect()
if __name__ == "__main__":
transcript_id = asyncio.run(create_and_process())
print(f"\n✅ Task submitted successfully!")
print(f"📍 Transcript ID: {transcript_id}")

View File

@@ -1,10 +1,21 @@
import os
from contextlib import asynccontextmanager
from tempfile import NamedTemporaryFile
from unittest.mock import patch
import pytest
@pytest.fixture(scope="session", autouse=True)
def register_mock_platform():
from mocks.mock_platform import MockPlatformClient
from reflector.video_platforms.registry import register_platform
register_platform("whereby", MockPlatformClient)
yield
@pytest.fixture(scope="session", autouse=True)
def settings_configuration():
# theses settings are linked to monadical for pytest-recording
@@ -337,6 +348,166 @@ async def client():
yield ac
@pytest.fixture(autouse=True)
async def ws_manager_in_memory(monkeypatch):
"""Replace Redis-based WS manager with an in-memory implementation for tests."""
import asyncio
import json
from reflector.ws_manager import WebsocketManager
class _InMemorySubscriber:
def __init__(self, queue: asyncio.Queue):
self.queue = queue
async def get_message(self, ignore_subscribe_messages: bool = True):
try:
return await asyncio.wait_for(self.queue.get(), timeout=0.05)
except Exception:
return None
class InMemoryPubSubManager:
def __init__(self):
self.queues: dict[str, asyncio.Queue] = {}
self.connected = False
async def connect(self) -> None:
self.connected = True
async def disconnect(self) -> None:
self.connected = False
async def send_json(self, room_id: str, message: dict) -> None:
if room_id not in self.queues:
self.queues[room_id] = asyncio.Queue()
payload = json.dumps(message).encode("utf-8")
await self.queues[room_id].put(
{"channel": room_id.encode("utf-8"), "data": payload}
)
async def subscribe(self, room_id: str):
if room_id not in self.queues:
self.queues[room_id] = asyncio.Queue()
return _InMemorySubscriber(self.queues[room_id])
async def unsubscribe(self, room_id: str) -> None:
# keep queue for potential later resubscribe within same test
pass
pubsub = InMemoryPubSubManager()
ws_manager = WebsocketManager(pubsub_client=pubsub)
def _get_ws_manager():
return ws_manager
# Patch all places that imported get_ws_manager at import time
monkeypatch.setattr("reflector.ws_manager.get_ws_manager", _get_ws_manager)
monkeypatch.setattr(
"reflector.pipelines.main_live_pipeline.get_ws_manager", _get_ws_manager
)
monkeypatch.setattr(
"reflector.views.transcripts_websocket.get_ws_manager", _get_ws_manager
)
monkeypatch.setattr(
"reflector.views.user_websocket.get_ws_manager", _get_ws_manager
)
monkeypatch.setattr("reflector.views.transcripts.get_ws_manager", _get_ws_manager)
# Websocket auth: avoid OAuth2 on websocket dependencies; allow anonymous
import reflector.auth as auth
# Ensure FastAPI uses our override for routes that captured the original callable
from reflector.app import app as fastapi_app
try:
fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None
except Exception:
pass
# Stub Redis cache used by profanity filter to avoid external Redis
from reflector import redis_cache as rc
class _FakeRedis:
def __init__(self):
self._data = {}
def get(self, key):
value = self._data.get(key)
if value is None:
return None
if isinstance(value, bytes):
return value
return str(value).encode("utf-8")
def setex(self, key, duration, value):
# ignore duration for tests
if isinstance(value, bytes):
self._data[key] = value
else:
self._data[key] = str(value).encode("utf-8")
fake_redises: dict[int, _FakeRedis] = {}
def _get_redis_client(db=0):
if db not in fake_redises:
fake_redises[db] = _FakeRedis()
return fake_redises[db]
monkeypatch.setattr(rc, "get_redis_client", _get_redis_client)
yield
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client():
async with authenticated_client_ctx():
yield
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client2():
async with authenticated_client2_ctx():
yield
@asynccontextmanager
async def authenticated_client_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@asynccontextmanager
async def authenticated_client2_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@pytest.fixture(scope="session")
def fake_mp3_upload():
with patch(

View File

View File

@@ -0,0 +1,111 @@
import uuid
from datetime import datetime
from typing import Any, Dict, Literal, Optional
from reflector.db.rooms import Room
from reflector.video_platforms.base import (
MeetingData,
VideoPlatformClient,
VideoPlatformConfig,
)
MockPlatform = Literal["mock"]
class MockPlatformClient(VideoPlatformClient):
PLATFORM_NAME: MockPlatform = "mock"
def __init__(self, config: VideoPlatformConfig):
super().__init__(config)
self._rooms: Dict[str, Dict[str, Any]] = {}
self._webhook_calls: list[Dict[str, Any]] = []
async def create_meeting(
self, room_name_prefix: str, end_date: datetime, room: Room
) -> MeetingData:
meeting_id = str(uuid.uuid4())
room_name = f"{room_name_prefix}-{meeting_id[:8]}"
room_url = f"https://mock.video/{room_name}"
host_room_url = f"{room_url}?host=true"
self._rooms[room_name] = {
"id": meeting_id,
"name": room_name,
"url": room_url,
"host_url": host_room_url,
"end_date": end_date,
"room": room,
"participants": [],
"is_active": True,
}
return MeetingData.model_construct(
meeting_id=meeting_id,
room_name=room_name,
room_url=room_url,
host_room_url=host_room_url,
platform="whereby",
extra_data={"mock": True},
)
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
if room_name not in self._rooms:
return {"error": "Room not found"}
room_data = self._rooms[room_name]
return {
"roomName": room_name,
"sessions": [
{
"sessionId": room_data["id"],
"startTime": datetime.utcnow().isoformat(),
"participants": room_data["participants"],
"isActive": room_data["is_active"],
}
],
}
async def delete_room(self, room_name: str) -> bool:
if room_name in self._rooms:
self._rooms[room_name]["is_active"] = False
return True
return False
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
if room_name in self._rooms:
self._rooms[room_name]["logo_path"] = logo_path
return True
return False
def verify_webhook_signature(
self, body: bytes, signature: str, timestamp: Optional[str] = None
) -> bool:
return signature == "valid"
def add_participant(
self, room_name: str, participant_id: str, participant_name: str
):
if room_name in self._rooms:
self._rooms[room_name]["participants"].append(
{
"id": participant_id,
"name": participant_name,
"joined_at": datetime.utcnow().isoformat(),
}
)
def trigger_webhook(self, event_type: str, data: Dict[str, Any]):
self._webhook_calls.append(
{
"type": event_type,
"data": data,
"timestamp": datetime.utcnow().isoformat(),
}
)
def get_webhook_calls(self) -> list[Dict[str, Any]]:
return self._webhook_calls.copy()
def clear_data(self):
self._rooms.clear()
self._webhook_calls.clear()

View File

@@ -11,14 +11,21 @@ from reflector.db.rooms import rooms_controller
@pytest.fixture
async def authenticated_client(client):
from reflector.app import app
from reflector.auth import current_user_optional
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "test-user",
"email": "test@example.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "test-user",
"email": "test@example.com",
}
yield client
del app.dependency_overrides[current_user_optional]
try:
yield client
finally:
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@pytest.mark.asyncio

View File

@@ -0,0 +1,384 @@
import asyncio
import shutil
import threading
import time
from pathlib import Path
import pytest
from httpx_ws import aconnect_ws
from uvicorn import Config, Server
from reflector import zulip as zulip_module
from reflector.app import app
from reflector.db import get_database
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import Room, rooms_controller
from reflector.db.transcripts import (
SourceKind,
TranscriptTopic,
transcripts_controller,
)
from reflector.processors.types import Word
from reflector.settings import settings
from reflector.views.transcripts import create_access_token
@pytest.mark.asyncio
async def test_anonymous_cannot_delete_transcript_in_shared_room(client):
# Create a shared room with a fake owner id so meeting has a room_id
room = await rooms_controller.add(
name="shared-room-test",
user_id="owner-1",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=True,
webhook_url="",
webhook_secret="",
)
# Create a meeting for that room (so transcript.meeting_id links to the shared room)
meeting = await meetings_controller.create(
id="meeting-sec-test",
room_name="room-sec-test",
room_url="room-url",
host_room_url="host-url",
start_date=Room.model_fields["created_at"].default_factory(),
end_date=Room.model_fields["created_at"].default_factory(),
room=room,
)
# Create a transcript owned by someone else and link it to meeting
t = await transcripts_controller.add(
name="to-delete",
source_kind=SourceKind.LIVE,
user_id="owner-2",
meeting_id=meeting.id,
room_id=room.id,
share_mode="private",
)
# Anonymous DELETE should be rejected
del_resp = await client.delete(f"/transcripts/{t.id}")
assert del_resp.status_code == 401, del_resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_mutate_participants_on_public_transcript(client):
# Create a public transcript with no owner
t = await transcripts_controller.add(
name="public-transcript",
source_kind=SourceKind.LIVE,
user_id=None,
share_mode="public",
)
# Anonymous POST participant must be rejected
resp = await client.post(
f"/transcripts/{t.id}/participants",
json={"name": "AnonUser", "speaker": 0},
)
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_update_and_delete_room(client):
# Create room as owner id "owner-3" via controller
room = await rooms_controller.add(
name="room-anon-update-delete",
user_id="owner-3",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="",
webhook_secret="",
)
# Anonymous PATCH via API (no auth)
resp = await client.patch(
f"/rooms/{room.id}",
json={
"name": "room-anon-updated",
"zulip_auto_post": False,
"zulip_stream": "",
"zulip_topic": "",
"is_locked": False,
"room_mode": "normal",
"recording_type": "cloud",
"recording_trigger": "automatic-2nd-participant",
"is_shared": False,
"webhook_url": "",
"webhook_secret": "",
},
)
# Expect authentication required
assert resp.status_code == 401, resp.text
# Anonymous DELETE via API
del_resp = await client.delete(f"/rooms/{room.id}")
assert del_resp.status_code == 401, del_resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_post_transcript_to_zulip(client, monkeypatch):
# Create a public transcript with some content
t = await transcripts_controller.add(
name="zulip-public",
source_kind=SourceKind.LIVE,
user_id=None,
share_mode="public",
)
# Mock send/update calls
def _fake_send_message_to_zulip(stream, topic, content):
return {"id": 12345}
async def _fake_update_message(message_id, stream, topic, content):
return {"result": "success"}
monkeypatch.setattr(
zulip_module, "send_message_to_zulip", _fake_send_message_to_zulip
)
monkeypatch.setattr(zulip_module, "update_zulip_message", _fake_update_message)
# Anonymous POST to Zulip endpoint
resp = await client.post(
f"/transcripts/{t.id}/zulip",
params={"stream": "general", "topic": "Updates", "include_topics": False},
)
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_assign_speaker_on_public_transcript(client):
# Create public transcript
t = await transcripts_controller.add(
name="public-assign",
source_kind=SourceKind.LIVE,
user_id=None,
share_mode="public",
)
# Add a topic with words to be reassigned
topic = TranscriptTopic(
title="T1",
summary="S1",
timestamp=0.0,
transcript="Hello",
words=[Word(start=0.0, end=1.0, text="Hello", speaker=0)],
)
transcript = await transcripts_controller.get_by_id(t.id)
await transcripts_controller.upsert_topic(transcript, topic)
# Anonymous assign speaker over time range covering the word
resp = await client.patch(
f"/transcripts/{t.id}/speaker/assign",
json={
"speaker": 1,
"timestamp_from": 0.0,
"timestamp_to": 1.0,
},
)
assert resp.status_code == 401, resp.text
# Minimal server fixture for websocket tests
@pytest.fixture
def appserver_ws_simple(setup_database):
host = "127.0.0.1"
port = 1256
server_started = threading.Event()
server_exception = None
server_instance = None
def run_server():
nonlocal server_exception, server_instance
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
config = Config(app=app, host=host, port=port, loop=loop)
server_instance = Server(config)
async def start_server():
database = get_database()
await database.connect()
try:
await server_instance.serve()
finally:
await database.disconnect()
server_started.set()
loop.run_until_complete(start_server())
except Exception as e:
server_exception = e
server_started.set()
finally:
loop.close()
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
server_started.wait(timeout=30)
if server_exception:
raise server_exception
time.sleep(0.5)
yield host, port
if server_instance:
server_instance.should_exit = True
server_thread.join(timeout=30)
@pytest.mark.asyncio
async def test_websocket_denies_anonymous_on_private_transcript(appserver_ws_simple):
host, port = appserver_ws_simple
# Create a private transcript owned by someone
t = await transcripts_controller.add(
name="private-ws",
source_kind=SourceKind.LIVE,
user_id="owner-x",
share_mode="private",
)
base_url = f"http://{host}:{port}/v1"
# Anonymous connect should be denied
with pytest.raises(Exception):
async with aconnect_ws(f"{base_url}/transcripts/{t.id}/events") as ws:
await ws.close()
@pytest.mark.asyncio
async def test_anonymous_cannot_update_public_transcript(client):
t = await transcripts_controller.add(
name="update-me",
source_kind=SourceKind.LIVE,
user_id=None,
share_mode="public",
)
resp = await client.patch(
f"/transcripts/{t.id}",
json={"title": "New Title From Anonymous"},
)
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_get_nonshared_room_by_id(client):
room = await rooms_controller.add(
name="private-room-exposed",
user_id="owner-z",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="",
webhook_secret="",
)
resp = await client.get(f"/rooms/{room.id}")
assert resp.status_code == 403, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_call_rooms_webhook_test(client):
room = await rooms_controller.add(
name="room-webhook-test",
user_id="owner-y",
zulip_auto_post=False,
zulip_stream="",
zulip_topic="",
is_locked=False,
room_mode="normal",
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
webhook_url="http://localhost.invalid/webhook",
webhook_secret="secret",
)
# Anonymous caller
resp = await client.post(f"/rooms/{room.id}/webhook/test")
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_anonymous_cannot_create_room(client):
payload = {
"name": "room-create-auth-required",
"zulip_auto_post": False,
"zulip_stream": "",
"zulip_topic": "",
"is_locked": False,
"room_mode": "normal",
"recording_type": "cloud",
"recording_trigger": "automatic-2nd-participant",
"is_shared": False,
"webhook_url": "",
"webhook_secret": "",
}
resp = await client.post("/rooms", json=payload)
assert resp.status_code == 401, resp.text
@pytest.mark.asyncio
async def test_list_search_401_when_public_mode_false(client, monkeypatch):
monkeypatch.setattr(settings, "PUBLIC_MODE", False)
resp = await client.get("/transcripts")
assert resp.status_code == 401
resp = await client.get("/transcripts/search", params={"q": "hello"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_audio_mp3_requires_token_for_owned_transcript(
client, tmpdir, monkeypatch
):
# Use temp data dir
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
# Create owner transcript and attach a local mp3
t = await transcripts_controller.add(
name="owned-audio",
source_kind=SourceKind.LIVE,
user_id="owner-a",
share_mode="private",
)
tr = await transcripts_controller.get_by_id(t.id)
await transcripts_controller.update(tr, {"status": "ended"})
# copy fixture audio to transcript path
audio_path = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(audio_path, tr.audio_mp3_filename)
# Anonymous GET without token should be 403 or 404 depending on access; we call mp3
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert resp.status_code == 403
# With token should succeed
token = create_access_token(
{"sub": tr.user_id}, expires_delta=__import__("datetime").timedelta(minutes=15)
)
resp2 = await client.get(f"/transcripts/{t.id}/audio/mp3", params={"token": token})
assert resp2.status_code == 200

View File

@@ -1,5 +1,3 @@
from contextlib import asynccontextmanager
import pytest
@@ -19,7 +17,7 @@ async def test_transcript_create(client):
@pytest.mark.asyncio
async def test_transcript_get_update_name(client):
async def test_transcript_get_update_name(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["name"] == "test"
@@ -40,7 +38,7 @@ async def test_transcript_get_update_name(client):
@pytest.mark.asyncio
async def test_transcript_get_update_locked(client):
async def test_transcript_get_update_locked(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["locked"] is False
@@ -61,7 +59,7 @@ async def test_transcript_get_update_locked(client):
@pytest.mark.asyncio
async def test_transcript_get_update_summary(client):
async def test_transcript_get_update_summary(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["long_summary"] is None
@@ -89,7 +87,7 @@ async def test_transcript_get_update_summary(client):
@pytest.mark.asyncio
async def test_transcript_get_update_title(client):
async def test_transcript_get_update_title(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["title"] is None
@@ -127,56 +125,6 @@ async def test_transcripts_list_anonymous(client):
settings.PUBLIC_MODE = False
@asynccontextmanager
async def authenticated_client_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid",
"email": "test@mail.com",
}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@asynccontextmanager
async def authenticated_client2_ctx():
from reflector.app import app
from reflector.auth import current_user, current_user_optional
app.dependency_overrides[current_user] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "randomuserid2",
"email": "test@mail.com",
}
yield
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client():
async with authenticated_client_ctx():
yield
@pytest.fixture
@pytest.mark.asyncio
async def authenticated_client2():
async with authenticated_client2_ctx():
yield
@pytest.mark.asyncio
async def test_transcripts_list_authenticated(authenticated_client, client):
# XXX this test is a bit fragile, as it depends on the storage which
@@ -199,7 +147,7 @@ async def test_transcripts_list_authenticated(authenticated_client, client):
@pytest.mark.asyncio
async def test_transcript_delete(client):
async def test_transcript_delete(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "testdel1"})
assert response.status_code == 200
assert response.json()["name"] == "testdel1"
@@ -214,7 +162,7 @@ async def test_transcript_delete(client):
@pytest.mark.asyncio
async def test_transcript_mark_reviewed(client):
async def test_transcript_mark_reviewed(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["name"] == "test"

View File

@@ -111,7 +111,9 @@ async def test_transcript_audio_download_range_with_seek(
@pytest.mark.asyncio
async def test_transcript_delete_with_audio(fake_transcript, client):
async def test_transcript_delete_with_audio(
authenticated_client, fake_transcript, client
):
response = await client.delete(f"/transcripts/{fake_transcript.id}")
assert response.status_code == 200
assert response.json()["status"] == "ok"

View File

@@ -2,7 +2,7 @@ import pytest
@pytest.mark.asyncio
async def test_transcript_participants(client):
async def test_transcript_participants(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["participants"] == []
@@ -39,7 +39,7 @@ async def test_transcript_participants(client):
@pytest.mark.asyncio
async def test_transcript_participants_same_speaker(client):
async def test_transcript_participants_same_speaker(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["participants"] == []
@@ -62,7 +62,7 @@ async def test_transcript_participants_same_speaker(client):
@pytest.mark.asyncio
async def test_transcript_participants_update_name(client):
async def test_transcript_participants_update_name(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["participants"] == []
@@ -100,7 +100,7 @@ async def test_transcript_participants_update_name(client):
@pytest.mark.asyncio
async def test_transcript_participants_update_speaker(client):
async def test_transcript_participants_update_speaker(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["participants"] == []

View File

@@ -2,7 +2,9 @@ import pytest
@pytest.mark.asyncio
async def test_transcript_reassign_speaker(fake_transcript_with_topics, client):
async def test_transcript_reassign_speaker(
authenticated_client, fake_transcript_with_topics, client
):
transcript_id = fake_transcript_with_topics.id
# check the transcript exists
@@ -114,7 +116,9 @@ async def test_transcript_reassign_speaker(fake_transcript_with_topics, client):
@pytest.mark.asyncio
async def test_transcript_merge_speaker(fake_transcript_with_topics, client):
async def test_transcript_merge_speaker(
authenticated_client, fake_transcript_with_topics, client
):
transcript_id = fake_transcript_with_topics.id
# check the transcript exists
@@ -181,7 +185,7 @@ async def test_transcript_merge_speaker(fake_transcript_with_topics, client):
@pytest.mark.asyncio
async def test_transcript_reassign_with_participant(
fake_transcript_with_topics, client
authenticated_client, fake_transcript_with_topics, client
):
transcript_id = fake_transcript_with_topics.id
@@ -347,7 +351,9 @@ async def test_transcript_reassign_with_participant(
@pytest.mark.asyncio
async def test_transcript_reassign_edge_cases(fake_transcript_with_topics, client):
async def test_transcript_reassign_edge_cases(
authenticated_client, fake_transcript_with_topics, client
):
transcript_id = fake_transcript_with_topics.id
# check the transcript exists

View File

@@ -0,0 +1,156 @@
import asyncio
import threading
import time
import pytest
from httpx_ws import aconnect_ws
from uvicorn import Config, Server
@pytest.fixture
def appserver_ws_user(setup_database):
from reflector.app import app
from reflector.db import get_database
host = "127.0.0.1"
port = 1257
server_started = threading.Event()
server_exception = None
server_instance = None
def run_server():
nonlocal server_exception, server_instance
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
config = Config(app=app, host=host, port=port, loop=loop)
server_instance = Server(config)
async def start_server():
database = get_database()
await database.connect()
try:
await server_instance.serve()
finally:
await database.disconnect()
server_started.set()
loop.run_until_complete(start_server())
except Exception as e:
server_exception = e
server_started.set()
finally:
loop.close()
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
server_started.wait(timeout=30)
if server_exception:
raise server_exception
time.sleep(0.5)
yield host, port
if server_instance:
server_instance.should_exit = True
server_thread.join(timeout=30)
@pytest.fixture(autouse=True)
def patch_jwt_verification(monkeypatch):
"""Patch JWT verification to accept HS256 tokens signed with SECRET_KEY for tests."""
from jose import jwt
from reflector.settings import settings
def _verify_token(self, token: str):
# Do not validate audience in tests
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) # type: ignore[arg-type]
monkeypatch.setattr(
"reflector.auth.auth_jwt.JWTAuth.verify_token", _verify_token, raising=True
)
def _make_dummy_jwt(sub: str = "user123") -> str:
# Create a short HS256 JWT using the app secret to pass verification in tests
from datetime import datetime, timedelta, timezone
from jose import jwt
from reflector.settings import settings
payload = {
"sub": sub,
"email": f"{sub}@example.com",
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
# Note: production uses RS256 public key verification; tests can sign with SECRET_KEY
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
@pytest.mark.asyncio
async def test_user_ws_rejects_missing_subprotocol(appserver_ws_user):
host, port = appserver_ws_user
base_ws = f"http://{host}:{port}/v1/events"
# No subprotocol/header with token
with pytest.raises(Exception):
async with aconnect_ws(base_ws) as ws: # type: ignore
# Should close during handshake; if not, close explicitly
await ws.close()
@pytest.mark.asyncio
async def test_user_ws_rejects_invalid_token(appserver_ws_user):
host, port = appserver_ws_user
base_ws = f"http://{host}:{port}/v1/events"
# Send wrong token via WebSocket subprotocols
protocols = ["bearer", "totally-invalid-token"]
with pytest.raises(Exception):
async with aconnect_ws(base_ws, subprotocols=protocols) as ws: # type: ignore
await ws.close()
@pytest.mark.asyncio
async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user):
host, port = appserver_ws_user
base_ws = f"http://{host}:{port}/v1/events"
token = _make_dummy_jwt("user-abc")
subprotocols = ["bearer", token]
# Connect and then trigger an event via HTTP create
async with aconnect_ws(base_ws, subprotocols=subprotocols) as ws:
# Emit an event to the user's room via a standard HTTP action
from httpx import AsyncClient
from reflector.app import app
from reflector.auth import current_user, current_user_optional
# Override auth dependencies so HTTP request is performed as the same user
app.dependency_overrides[current_user] = lambda: {
"sub": "user-abc",
"email": "user-abc@example.com",
}
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "user-abc",
"email": "user-abc@example.com",
}
async with AsyncClient(app=app, base_url=f"http://{host}:{port}/v1") as ac:
# Create a transcript as this user so that the server publishes TRANSCRIPT_CREATED to user room
resp = await ac.post("/transcripts", json={"name": "WS Test"})
assert resp.status_code == 200
# Receive the published event
msg = await ws.receive_json()
assert msg["event"] == "TRANSCRIPT_CREATED"
assert "id" in msg["data"]
# Clean overrides
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""
Trigger reprocessing of Daily.co multitrack recording via Celery
"""
from reflector.pipelines.main_multitrack_pipeline import (
task_pipeline_multitrack_process,
)
# Trigger the Celery task
result = task_pipeline_multitrack_process.delay(
transcript_id="32fad706-f8cf-434c-94c8-1ee69f7be081", # The ID that was created
bucket_name="reflector-dailyco-local",
track_keys=[
"monadical/daily-20251020193458/1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922",
"monadical/daily-20251020193458/1760988935484-a37c35e3-6f8e-4274-a482-e9d0f102a732-cam-audio-1760988943823",
],
)
print(f"Task ID: {result.id}")
print(
f"Processing started! Check: http://localhost:3000/transcripts/32fad706-f8cf-434c-94c8-1ee69f7be081"
)

14
www/.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.env
.env.*
.env.local
.env.development
.env.production
node_modules
.next
.git
.gitignore
*.md
.DS_Store
coverage
.pnpm-store
*.log

View File

@@ -1,9 +1,5 @@
# Environment
ENVIRONMENT=development
NEXT_PUBLIC_ENV=development
# Site Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000
SITE_URL=http://localhost:3000
# Nextauth envs
# not used in app code but in lib code
@@ -18,16 +14,16 @@ AUTHENTIK_CLIENT_ID=your-client-id-here
AUTHENTIK_CLIENT_SECRET=your-client-secret-here
# Feature Flags
# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
# NEXT_PUBLIC_FEATURE_PRIVACY=false
# NEXT_PUBLIC_FEATURE_BROWSE=true
# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
# NEXT_PUBLIC_FEATURE_ROOMS=true
# FEATURE_REQUIRE_LOGIN=true
# FEATURE_PRIVACY=false
# FEATURE_BROWSE=true
# FEATURE_SEND_TO_ZULIP=true
# FEATURE_ROOMS=true
# API URLs
NEXT_PUBLIC_API_URL=http://127.0.0.1:1250
NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250
NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
API_URL=http://127.0.0.1:1250
WEBSOCKET_URL=ws://127.0.0.1:1250
AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
# Sentry
# SENTRY_DSN=https://your-dsn@sentry.io/project-id

1
www/.npmrc Normal file
View File

@@ -0,0 +1 @@
minimum-release-age=1440 #24hr in minutes

81
www/DOCKER_README.md Normal file
View File

@@ -0,0 +1,81 @@
# Docker Production Build Guide
## Overview
The Docker image builds without any environment variables and requires all configuration to be provided at runtime.
## Environment Variables (ALL Runtime)
### Required Runtime Variables
```bash
API_URL # Backend API URL (e.g., https://api.example.com)
WEBSOCKET_URL # WebSocket URL (e.g., wss://api.example.com)
NEXTAUTH_URL # NextAuth base URL (e.g., https://app.example.com)
NEXTAUTH_SECRET # Random secret for NextAuth (generate with: openssl rand -base64 32)
KV_URL # Redis URL (e.g., redis://redis:6379)
```
### Optional Runtime Variables
```bash
SITE_URL # Frontend URL (defaults to NEXTAUTH_URL)
AUTHENTIK_ISSUER # OAuth issuer URL
AUTHENTIK_CLIENT_ID # OAuth client ID
AUTHENTIK_CLIENT_SECRET # OAuth client secret
AUTHENTIK_REFRESH_TOKEN_URL # OAuth token refresh URL
FEATURE_REQUIRE_LOGIN=false # Require authentication
FEATURE_PRIVACY=true # Enable privacy features
FEATURE_BROWSE=true # Enable browsing features
FEATURE_SEND_TO_ZULIP=false # Enable Zulip integration
FEATURE_ROOMS=true # Enable rooms feature
SENTRY_DSN # Sentry error tracking
AUTH_CALLBACK_URL # OAuth callback URL
```
## Building the Image
### Option 1: Using Docker Compose
1. Build the image (no environment variables needed):
```bash
docker compose -f docker-compose.prod.yml build
```
2. Create a `.env` file with runtime variables
3. Run with environment variables:
```bash
docker compose -f docker-compose.prod.yml --env-file .env up -d
```
### Option 2: Using Docker CLI
1. Build the image (no build args):
```bash
docker build -t reflector-frontend:latest ./www
```
2. Run with environment variables:
```bash
docker run -d \
-p 3000:3000 \
-e API_URL=https://api.example.com \
-e WEBSOCKET_URL=wss://api.example.com \
-e NEXTAUTH_URL=https://app.example.com \
-e NEXTAUTH_SECRET=your-secret \
-e KV_URL=redis://redis:6379 \
-e AUTHENTIK_ISSUER=https://auth.example.com/application/o/reflector \
-e AUTHENTIK_CLIENT_ID=your-client-id \
-e AUTHENTIK_CLIENT_SECRET=your-client-secret \
-e AUTHENTIK_REFRESH_TOKEN_URL=https://auth.example.com/application/o/token/ \
-e FEATURE_REQUIRE_LOGIN=true \
reflector-frontend:latest
```

View File

@@ -24,7 +24,8 @@ COPY --link . .
ENV NEXT_TELEMETRY_DISABLED 1
# If using npm comment out above and use below instead
RUN pnpm build
# next.js has the feature of excluding build step planned https://github.com/vercel/next.js/discussions/46544
RUN pnpm build-production
# RUN npm run build
# Production image, copy all the files and run next
@@ -51,6 +52,10 @@ USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME localhost
ENV HOSTNAME 0.0.0.0
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health \
|| exit 1
CMD ["node", "server.js"]

View File

@@ -27,7 +27,7 @@ import {
} from "../../../lib/utils";
interface ICSSettingsProps {
roomName: NonEmptyString;
roomName: NonEmptyString | null;
icsUrl?: string;
icsEnabled?: boolean;
icsFetchInterval?: number;
@@ -85,7 +85,7 @@ export default function ICSSettings({
const handleCopyRoomUrl = async () => {
try {
await navigator.clipboard.writeText(
roomAbsoluteUrl(assertExistsAndNonEmptyString(roomName)),
roomAbsoluteUrl(assertExists(roomName)),
);
setJustCopied(true);
@@ -123,7 +123,7 @@ export default function ICSSettings({
const handleRoomUrlClick = () => {
if (roomUrlInputRef.current) {
roomUrlInputRef.current.select();
handleCopyRoomUrl();
handleCopyRoomUrl().then(() => {});
}
};
@@ -196,30 +196,38 @@ export default function ICSSettings({
To enable Reflector to recognize your calendar events as meetings,
add this URL as the location in your calendar events
</Field.HelperText>
<HStack gap={0} position="relative" width="100%">
<Input
ref={roomUrlInputRef}
value={roomAbsoluteUrl(parseNonEmptyString(roomName))}
readOnly
onClick={handleRoomUrlClick}
cursor="pointer"
bg="gray.100"
_hover={{ bg: "gray.200" }}
_focus={{ bg: "gray.200" }}
pr="90px"
width="100%"
/>
<HStack position="absolute" right="4px" gap={1} zIndex={1}>
<IconButton
aria-label="Copy room URL"
onClick={handleCopyRoomUrl}
variant="ghost"
size="sm"
>
{justCopied ? <LuCheck /> : <LuCopy />}
</IconButton>
{roomName ? (
<HStack gap={0} position="relative" width="100%">
<Input
ref={roomUrlInputRef}
value={roomAbsoluteUrl(
parseNonEmptyString(
roomName,
true,
"panic! roomName is required",
),
)}
readOnly
onClick={handleRoomUrlClick}
cursor="pointer"
bg="gray.100"
_hover={{ bg: "gray.200" }}
_focus={{ bg: "gray.200" }}
pr="90px"
width="100%"
/>
<HStack position="absolute" right="4px" gap={1} zIndex={1}>
<IconButton
aria-label="Copy room URL"
onClick={handleCopyRoomUrl}
variant="ghost"
size="sm"
>
{justCopied ? <LuCheck /> : <LuCopy />}
</IconButton>
</HStack>
</HStack>
</HStack>
) : null}
</Field.Root>
<Field.Root>

View File

@@ -274,15 +274,31 @@ export function RoomTable({
<IconButton
aria-label="Force sync calendar"
onClick={() =>
handleForceSync(parseNonEmptyString(room.name))
handleForceSync(
parseNonEmptyString(
room.name,
true,
"panic! room.name is required",
),
)
}
size="sm"
variant="ghost"
disabled={syncingRooms.has(
parseNonEmptyString(room.name),
parseNonEmptyString(
room.name,
true,
"panic! room.name is required",
),
)}
>
{syncingRooms.has(parseNonEmptyString(room.name)) ? (
{syncingRooms.has(
parseNonEmptyString(
room.name,
true,
"panic! room.name is required",
),
) ? (
<Spinner size="sm" />
) : (
<CalendarSyncIcon />
@@ -297,7 +313,13 @@ export function RoomTable({
<IconButton
aria-label="Copy URL"
onClick={() =>
onCopyUrl(parseNonEmptyString(room.name))
onCopyUrl(
parseNonEmptyString(
room.name,
true,
"panic! room.name is required",
),
)
}
size="sm"
variant="ghost"

View File

@@ -309,7 +309,7 @@ export default function RoomsList() {
setRoomInput(null);
setIsEditing(false);
setEditRoomId("");
setEditRoomId(null);
setNameError("");
refetch();
onClose();
@@ -449,415 +449,434 @@ export default function RoomsList() {
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
<Tabs.Root defaultValue="general">
<Tabs.List>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
<Tabs.Trigger value="share">Share</Tabs.Trigger>
<Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
</Tabs.List>
<form
id="room-form"
onSubmit={(e) => {
e.preventDefault();
handleSaveRoom();
}}
>
<Tabs.Root defaultValue="general">
<Tabs.List>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
<Tabs.Trigger value="share">Share</Tabs.Trigger>
<Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general" pt={6}>
<Field.Root>
<Field.Label>Room name</Field.Label>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
/>
<Field.HelperText>
No spaces or special characters allowed
</Field.HelperText>
{nameError && (
<Field.ErrorText>{nameError}</Field.ErrorText>
)}
</Field.Root>
<Tabs.Content value="general" pt={6}>
<Field.Root>
<Field.Label>Room name</Field.Label>
<Input
name="name"
placeholder="room-name"
value={room.name}
onChange={handleRoomChange}
enterKeyHint="next"
/>
<Field.HelperText>
No spaces or special characters allowed
</Field.HelperText>
{nameError && (
<Field.ErrorText>{nameError}</Field.ErrorText>
)}
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isLocked"
checked={room.isLocked}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isLocked",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Locked room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isLocked"
checked={room.isLocked}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isLocked",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Locked room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Recording type</Field.Label>
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingType: e.value[0],
recordingTrigger:
e.value[0] !== "cloud"
? "none"
: room.recordingTrigger,
})
}
collection={recordingTypeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select recording type" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTypeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Cloud recording start trigger</Field.Label>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingTrigger: e.value[0],
})
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Room size</Field.Label>
<Select.Root
value={[room.roomMode]}
onValueChange={(e) =>
setRoomInput({ ...room, roomMode: e.value[0] })
}
collection={roomModeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select room size" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{roomModeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isShared"
checked={room.isShared}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isShared",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
</Tabs.Content>
<Field.Root mt={4}>
<Field.Label>Recording type</Field.Label>
<Select.Root
value={[room.recordingType]}
onValueChange={(e) =>
setRoomInput({
...room,
recordingType: e.value[0],
recordingTrigger:
e.value[0] !== "cloud"
? "none"
: room.recordingTrigger,
})
}
collection={recordingTypeCollection}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select recording type" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTypeOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Tabs.Content value="share" pt={6}>
<Field.Root>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</Tabs.Content>
<Field.Root mt={4}>
<Field.Label>Cloud recording start trigger</Field.Label>
<Select.Root
value={[room.recordingTrigger]}
onValueChange={(e) =>
setRoomInput({ ...room, recordingTrigger: e.value[0] })
}
collection={recordingTriggerCollection}
disabled={room.recordingType !== "cloud"}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select trigger" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{recordingTriggerOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Tabs.Content value="webhook" pt={6}>
<Field.Root>
<Field.Label>Webhook URL</Field.Label>
<Input
name="webhookUrl"
placeholder="https://example.com/webhook"
value={room.webhookUrl}
onChange={handleRoomChange}
enterKeyHint="next"
/>
<Field.HelperText>
Optional: URL to receive notifications when transcripts
are ready
</Field.HelperText>
</Field.Root>
<Field.Root mt={4}>
<Checkbox.Root
name="isShared"
checked={room.isShared}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "isShared",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>Shared room</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
</Tabs.Content>
<Tabs.Content value="calendar" pt={6}>
<ICSSettings
roomName={parseNonEmptyString(room.name)}
icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled}
icsFetchInterval={room.icsFetchInterval}
onChange={(settings) => {
setRoomInput({
...room,
icsUrl:
settings.ics_url !== undefined
? settings.ics_url
: room.icsUrl,
icsEnabled:
settings.ics_enabled !== undefined
? settings.ics_enabled
: room.icsEnabled,
icsFetchInterval:
settings.ics_fetch_interval !== undefined
? settings.ics_fetch_interval
: room.icsFetchInterval,
});
}}
isOwner={true}
isEditing={isEditing}
/>
</Tabs.Content>
<Tabs.Content value="share" pt={6}>
<Field.Root>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</Tabs.Content>
<Tabs.Content value="webhook" pt={6}>
<Field.Root>
<Field.Label>Webhook URL</Field.Label>
<Input
name="webhookUrl"
type="url"
placeholder="https://example.com/webhook"
value={room.webhookUrl}
onChange={handleRoomChange}
/>
<Field.HelperText>
Optional: URL to receive notifications when transcripts
are ready
</Field.HelperText>
</Field.Root>
{room.webhookUrl && (
<>
<Field.Root mt={4}>
<Field.Label>Webhook Secret</Field.Label>
<Flex gap={2}>
<Input
name="webhookSecret"
type={showWebhookSecret ? "text" : "password"}
value={room.webhookSecret}
onChange={handleRoomChange}
placeholder={
isEditing && room.webhookSecret
? "••••••••"
: "Leave empty to auto-generate"
}
flex="1"
/>
{isEditing && room.webhookSecret && (
<IconButton
size="sm"
variant="ghost"
aria-label={
showWebhookSecret
? "Hide secret"
: "Show secret"
{room.webhookUrl && (
<>
<Field.Root mt={4}>
<Field.Label>Webhook Secret</Field.Label>
<Flex gap={2}>
<Input
name="webhookSecret"
type={showWebhookSecret ? "text" : "password"}
value={room.webhookSecret}
onChange={handleRoomChange}
placeholder={
isEditing && room.webhookSecret
? "••••••••"
: "Leave empty to auto-generate"
}
onClick={() =>
setShowWebhookSecret(!showWebhookSecret)
}
>
{showWebhookSecret ? <LuEyeOff /> : <LuEye />}
</IconButton>
)}
</Flex>
<Field.HelperText>
Used for HMAC signature verification (auto-generated
if left empty)
</Field.HelperText>
</Field.Root>
{isEditing && (
<>
<Flex
mt={2}
gap={2}
alignItems="flex-start"
direction="column"
>
<Button
size="sm"
variant="outline"
onClick={handleTestWebhook}
disabled={testingWebhook || !room.webhookUrl}
>
{testingWebhook ? (
<>
<Spinner size="xs" mr={2} />
Testing...
</>
) : (
"Test Webhook"
)}
</Button>
{webhookTestResult && (
<div
style={{
fontSize: "14px",
wordBreak: "break-word",
maxWidth: "100%",
padding: "8px",
borderRadius: "4px",
backgroundColor: webhookTestResult.startsWith(
SUCCESS_EMOJI,
)
? "#f0fdf4"
: "#fef2f2",
border: `1px solid ${webhookTestResult.startsWith(SUCCESS_EMOJI) ? "#86efac" : "#fca5a5"}`,
}}
flex="1"
/>
{isEditing && room.webhookSecret && (
<IconButton
size="sm"
variant="ghost"
aria-label={
showWebhookSecret
? "Hide secret"
: "Show secret"
}
onClick={() =>
setShowWebhookSecret(!showWebhookSecret)
}
>
{webhookTestResult}
</div>
{showWebhookSecret ? <LuEyeOff /> : <LuEye />}
</IconButton>
)}
</Flex>
</>
)}
</>
)}
</Tabs.Content>
</Tabs.Root>
<Field.HelperText>
Used for HMAC signature verification (auto-generated
if left empty)
</Field.HelperText>
</Field.Root>
{isEditing && (
<>
<Flex
mt={2}
gap={2}
alignItems="flex-start"
direction="column"
>
<Button
size="sm"
variant="outline"
onClick={handleTestWebhook}
disabled={testingWebhook || !room.webhookUrl}
>
{testingWebhook ? (
<>
<Spinner size="xs" mr={2} />
Testing...
</>
) : (
"Test Webhook"
)}
</Button>
{webhookTestResult && (
<div
style={{
fontSize: "14px",
wordBreak: "break-word",
maxWidth: "100%",
padding: "8px",
borderRadius: "4px",
backgroundColor:
webhookTestResult.startsWith(
SUCCESS_EMOJI,
)
? "#f0fdf4"
: "#fef2f2",
border: `1px solid ${webhookTestResult.startsWith(SUCCESS_EMOJI) ? "#86efac" : "#fca5a5"}`,
}}
>
{webhookTestResult}
</div>
)}
</Flex>
</>
)}
</>
)}
</Tabs.Content>
<Tabs.Content value="calendar" pt={6}>
<Field.Root>
<ICSSettings
roomName={
room.name
? parseNonEmptyString(
room.name,
true,
"panic! room.name required",
)
: null
}
icsUrl={room.icsUrl}
icsEnabled={room.icsEnabled}
icsFetchInterval={room.icsFetchInterval}
onChange={(settings) => {
setRoomInput({
...room,
icsUrl:
settings.ics_url !== undefined
? settings.ics_url
: room.icsUrl,
icsEnabled:
settings.ics_enabled !== undefined
? settings.ics_enabled
: room.icsEnabled,
icsFetchInterval:
settings.ics_fetch_interval !== undefined
? settings.ics_fetch_interval
: room.icsFetchInterval,
});
}}
isOwner={true}
isEditing={isEditing}
/>
</Field.Root>
</Tabs.Content>
</Tabs.Root>
</form>
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={handleCloseDialog}>
Cancel
</Button>
<Button
type="submit"
colorPalette="primary"
onClick={handleSaveRoom}
form="room-form"
disabled={
!room.name || (room.zulipAutoPost && !room.zulipTopic)
}

View File

@@ -62,7 +62,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
useEffect(() => {
document.onkeyup = (e) => {
if (e.key === "a" && process.env.NEXT_PUBLIC_ENV === "development") {
if (e.key === "a" && process.env.NODE_ENV === "development") {
const segments: GetTranscriptSegmentTopic[] = [
{
speaker: 1,
@@ -201,7 +201,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
setFinalSummary({ summary: "This is the final summary" });
}
if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") {
if (e.key === "z" && process.env.NODE_ENV === "development") {
setTranscriptTextLive(
"This text is in English, and it is a pretty long sentence to test the limits",
);

View File

@@ -0,0 +1,237 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Button, Text, VStack, HStack, Icon } from "@chakra-ui/react";
import { toaster } from "../../components/ui/toaster";
import { useRouter } from "next/navigation";
import { useRecordingConsent } from "../../recordingConsentContext";
import { useMeetingAudioConsent } from "../../lib/apiHooks";
import { FaBars } from "react-icons/fa6";
import DailyIframe, { DailyCall } from "@daily-co/daily-js";
import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
type Meeting = components["schemas"]["Meeting"];
const CONSENT_BUTTON_TOP_OFFSET = "56px";
const TOAST_CHECK_INTERVAL_MS = 100;
interface DailyRoomProps {
meeting: Meeting;
}
function ConsentDialogButton({ meetingId }: { meetingId: string }) {
const { state: consentState, touch, hasConsent } = useRecordingConsent();
const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent();
const handleConsent = useCallback(
async (meetingId: string, given: boolean) => {
try {
await audioConsentMutation.mutateAsync({
params: {
path: {
meeting_id: meetingId,
},
},
body: {
consent_given: given,
},
});
touch(meetingId);
} catch (error) {
console.error("Error submitting consent:", error);
}
},
[audioConsentMutation, touch],
);
const showConsentModal = useCallback(() => {
if (modalOpen) return;
setModalOpen(true);
const toastId = toaster.create({
placement: "top",
duration: null,
render: ({ dismiss }) => (
<Box
p={6}
bg="rgba(255, 255, 255, 0.7)"
borderRadius="lg"
boxShadow="lg"
maxW="md"
mx="auto"
>
<VStack gap={4} alignItems="center">
<Text fontSize="md" textAlign="center" fontWeight="medium">
Can we have your permission to store this meeting's audio
recording on our servers?
</Text>
<HStack gap={4} justifyContent="center">
<Button
variant="ghost"
size="sm"
onClick={() => {
handleConsent(meetingId, false).then(() => {
/*signifies it's ok to now wait here.*/
});
dismiss();
}}
>
No, delete after transcription
</Button>
<Button
colorPalette="primary"
size="sm"
onClick={() => {
handleConsent(meetingId, true).then(() => {
/*signifies it's ok to now wait here.*/
});
dismiss();
}}
>
Yes, store the audio
</Button>
</HStack>
</VStack>
</Box>
),
});
toastId.then((id) => {
const checkToastStatus = setInterval(() => {
if (!toaster.isActive(id)) {
setModalOpen(false);
clearInterval(checkToastStatus);
}
}, TOAST_CHECK_INTERVAL_MS);
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
toastId.then((id) => toaster.dismiss(id));
}
};
document.addEventListener("keydown", handleKeyDown);
const cleanup = () => {
toastId.then((id) => toaster.dismiss(id));
document.removeEventListener("keydown", handleKeyDown);
};
return cleanup;
}, [meetingId, handleConsent, modalOpen]);
if (
!consentState.ready ||
hasConsent(meetingId) ||
audioConsentMutation.isPending
) {
return null;
}
return (
<Button
position="absolute"
top={CONSENT_BUTTON_TOP_OFFSET}
left="8px"
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={showConsentModal}
>
Meeting is being recorded
<Icon as={FaBars} ml={2} />
</Button>
);
}
const recordingTypeRequiresConsent = (
recordingType: Meeting["recording_type"],
) => {
return recordingType === "cloud";
};
export default function DailyRoom({ meeting }: DailyRoomProps) {
const router = useRouter();
const auth = useAuth();
const status = auth.status;
const isAuthenticated = status === "authenticated";
const [callFrame, setCallFrame] = useState<DailyCall | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const roomUrl = meeting?.host_room_url || meeting?.room_url;
const isLoading = status === "loading";
const handleLeave = useCallback(() => {
router.push("/browse");
}, [router]);
useEffect(() => {
if (isLoading || !roomUrl || !containerRef.current) return;
let frame: DailyCall | null = null;
let destroyed = false;
const createAndJoin = async () => {
try {
const existingFrame = DailyIframe.getCallInstance();
if (existingFrame) {
await existingFrame.destroy();
}
frame = DailyIframe.createFrame(containerRef.current!, {
iframeStyle: {
width: "100vw",
height: "100vh",
border: "none",
},
showLeaveButton: true,
showFullscreenButton: true,
});
if (destroyed) {
await frame.destroy();
return;
}
frame.on("left-meeting", handleLeave);
await frame.join({ url: roomUrl });
if (!destroyed) {
setCallFrame(frame);
}
} catch (error) {
console.error("Error creating Daily frame:", error);
}
};
createAndJoin();
return () => {
destroyed = true;
if (frame) {
frame.destroy().catch((e) => {
console.error("Error destroying frame:", e);
});
}
};
}, [roomUrl, isLoading, handleLeave]);
if (!roomUrl) {
return null;
}
return (
<Box position="relative" width="100vw" height="100vh">
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{recordingTypeRequiresConsent(meeting.recording_type) && (
<ConsentDialogButton meetingId={meeting.id} />
)}
</Box>
);
}

View File

@@ -0,0 +1,214 @@
"use client";
import { roomMeetingUrl } from "../../lib/routes";
import { useCallback, useEffect, useState, use } from "react";
import { Box, Text, Spinner } from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import {
useRoomGetByName,
useRoomsCreateMeeting,
useRoomGetMeeting,
} from "../../lib/apiHooks";
import type { components } from "../../reflector-api";
import MeetingSelection from "../MeetingSelection";
import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
import WherebyRoom from "./WherebyRoom";
import DailyRoom from "./DailyRoom";
import { useAuth } from "../../lib/AuthProvider";
import { useError } from "../../(errors)/errorContext";
import { parseNonEmptyString } from "../../lib/utils";
import { printApiError } from "../../api/_error";
type Meeting = components["schemas"]["Meeting"];
export type RoomDetails = {
params: Promise<{
roomName: string;
meetingId?: string;
}>;
};
function LoadingSpinner() {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Spinner color="blue.500" size="xl" />
</Box>
);
}
export default function RoomContainer(details: RoomDetails) {
const params = use(details.params);
const roomName = parseNonEmptyString(
params.roomName,
true,
"panic! params.roomName is required",
);
const router = useRouter();
const auth = useAuth();
const status = auth.status;
const isAuthenticated = status === "authenticated";
const { setError } = useError();
const roomQuery = useRoomGetByName(roomName);
const createMeetingMutation = useRoomsCreateMeeting();
const room = roomQuery.data;
const pageMeetingId = params.meetingId;
const defaultMeeting = useRoomDefaultMeeting(
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
);
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
const meeting = explicitMeeting.data || defaultMeeting.response;
const isLoading =
status === "loading" ||
roomQuery.isLoading ||
defaultMeeting?.loading ||
explicitMeeting.isLoading ||
createMeetingMutation.isPending;
const errors = [
explicitMeeting.error,
defaultMeeting.error,
roomQuery.error,
createMeetingMutation.error,
].filter(Boolean);
const isOwner =
isAuthenticated && room ? auth.user?.id === room.user_id : false;
const handleMeetingSelect = (selectedMeeting: Meeting) => {
router.push(
roomMeetingUrl(
roomName,
parseNonEmptyString(
selectedMeeting.id,
true,
"panic! selectedMeeting.id is required",
),
),
);
};
const handleCreateUnscheduled = async () => {
try {
const newMeeting = await createMeetingMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
body: {
allow_duplicated: room ? room.ics_enabled : false,
},
});
handleMeetingSelect(newMeeting);
} catch (err) {
console.error("Failed to create meeting:", err);
}
};
if (isLoading) {
return <LoadingSpinner />;
}
if (!room) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Room not found</Text>
</Box>
);
}
if (room.ics_enabled && !params.meetingId) {
return (
<MeetingSelection
roomName={roomName}
isOwner={isOwner}
isSharedRoom={room?.is_shared || false}
authLoading={["loading", "refreshing"].includes(auth.status)}
onMeetingSelect={handleMeetingSelect}
onCreateUnscheduled={handleCreateUnscheduled}
isCreatingMeeting={createMeetingMutation.isPending}
/>
);
}
if (errors.length > 0) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
{errors.map((error, i) => (
<Text key={i} fontSize="lg">
{printApiError(error)}
</Text>
))}
</Box>
);
}
if (!meeting) {
return <LoadingSpinner />;
}
const platform = meeting.platform;
if (!platform) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Meeting platform not configured</Text>
</Box>
);
}
switch (platform) {
case "daily":
return <DailyRoom meeting={meeting} />;
case "whereby":
return <WherebyRoom meeting={meeting} />;
default: {
const _exhaustive: never = platform;
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Unknown platform: {platform}</Text>
</Box>
);
}
}
}

View File

@@ -0,0 +1,271 @@
"use client";
import { useCallback, useEffect, useRef, useState, RefObject } from "react";
import { Box, Button, Text, VStack, HStack, Icon } from "@chakra-ui/react";
import { toaster } from "../../components/ui/toaster";
import { useRouter } from "next/navigation";
import { useRecordingConsent } from "../../recordingConsentContext";
import { useMeetingAudioConsent } from "../../lib/apiHooks";
import type { components } from "../../reflector-api";
import { FaBars } from "react-icons/fa6";
import { useAuth } from "../../lib/AuthProvider";
import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient";
import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils";
type Meeting = components["schemas"]["Meeting"];
interface WherebyRoomProps {
meeting: Meeting;
}
const useConsentWherebyFocusManagement = (
acceptButtonRef: RefObject<HTMLButtonElement>,
wherebyRef: RefObject<HTMLElement>,
) => {
const currentFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (acceptButtonRef.current) {
acceptButtonRef.current.focus();
} else {
console.error(
"accept button ref not available yet for focus management - seems to be illegal state",
);
}
const handleWherebyReady = () => {
console.log("whereby ready - refocusing consent button");
currentFocusRef.current = document.activeElement as HTMLElement;
if (acceptButtonRef.current) {
acceptButtonRef.current.focus();
}
};
if (wherebyRef.current) {
wherebyRef.current.addEventListener("ready", handleWherebyReady);
} else {
console.warn(
"whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
);
}
return () => {
wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
currentFocusRef.current?.focus();
};
}, []);
};
const useConsentDialog = (
meetingId: string,
wherebyRef: RefObject<HTMLElement>,
) => {
const { state: consentState, touch, hasConsent } = useRecordingConsent();
const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent();
const handleConsent = useCallback(
async (meetingId: string, given: boolean) => {
try {
await audioConsentMutation.mutateAsync({
params: {
path: {
meeting_id: meetingId,
},
},
body: {
consent_given: given,
},
});
touch(meetingId);
} catch (error) {
console.error("Error submitting consent:", error);
}
},
[audioConsentMutation, touch],
);
const showConsentModal = useCallback(() => {
if (modalOpen) return;
setModalOpen(true);
const toastId = toaster.create({
placement: "top",
duration: null,
render: ({ dismiss }) => {
const AcceptButton = () => {
const buttonRef = useRef<HTMLButtonElement>(null);
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
return (
<Button
ref={buttonRef}
colorPalette="primary"
size="sm"
onClick={() => {
handleConsent(meetingId, true).then(() => {
/*signifies it's ok to now wait here.*/
});
dismiss();
}}
>
Yes, store the audio
</Button>
);
};
return (
<Box
p={6}
bg="rgba(255, 255, 255, 0.7)"
borderRadius="lg"
boxShadow="lg"
maxW="md"
mx="auto"
>
<VStack gap={4} alignItems="center">
<Text fontSize="md" textAlign="center" fontWeight="medium">
Can we have your permission to store this meeting's audio
recording on our servers?
</Text>
<HStack gap={4} justifyContent="center">
<Button
variant="ghost"
size="sm"
onClick={() => {
handleConsent(meetingId, false).then(() => {
/*signifies it's ok to now wait here.*/
});
dismiss();
}}
>
No, delete after transcription
</Button>
<AcceptButton />
</HStack>
</VStack>
</Box>
);
},
});
toastId.then((id) => {
const checkToastStatus = setInterval(() => {
if (!toaster.isActive(id)) {
setModalOpen(false);
clearInterval(checkToastStatus);
}
}, 100);
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
toastId.then((id) => toaster.dismiss(id));
}
};
document.addEventListener("keydown", handleKeyDown);
const cleanup = () => {
toastId.then((id) => toaster.dismiss(id));
document.removeEventListener("keydown", handleKeyDown);
};
return cleanup;
}, [meetingId, handleConsent, wherebyRef, modalOpen]);
return {
showConsentModal,
consentState,
hasConsent,
consentLoading: audioConsentMutation.isPending,
};
};
function ConsentDialogButton({
meetingId,
wherebyRef,
}: {
meetingId: NonEmptyString;
wherebyRef: React.RefObject<HTMLElement>;
}) {
const { showConsentModal, consentState, hasConsent, consentLoading } =
useConsentDialog(meetingId, wherebyRef);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
return null;
}
return (
<Button
position="absolute"
top="56px"
left="8px"
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={showConsentModal}
>
Meeting is being recorded
<Icon as={FaBars} ml={2} />
</Button>
);
}
const recordingTypeRequiresConsent = (
recordingType: NonNullable<Meeting["recording_type"]>,
) => {
return recordingType === "cloud";
};
export default function WherebyRoom({ meeting }: WherebyRoomProps) {
const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null);
const router = useRouter();
const auth = useAuth();
const status = auth.status;
const isAuthenticated = status === "authenticated";
const wherebyRoomUrl = getWherebyUrl(meeting);
const recordingType = meeting.recording_type;
const meetingId = meeting.id;
const isLoading = status === "loading";
const handleLeave = useCallback(() => {
router.push("/browse");
}, [router]);
useEffect(() => {
if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded)
return;
wherebyRef.current?.addEventListener("leave", handleLeave);
return () => {
wherebyRef.current?.removeEventListener("leave", handleLeave);
};
}, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]);
if (!wherebyRoomUrl || !wherebyLoaded) {
return null;
}
return (
<>
<whereby-embed
ref={wherebyRef}
room={wherebyRoomUrl}
style={{ width: "100vw", height: "100vh" }}
/>
{recordingType &&
recordingTypeRequiresConsent(recordingType) &&
meetingId && (
<ConsentDialogButton
meetingId={assertExistsAndNonEmptyString(meetingId)}
wherebyRef={wherebyRef}
/>
)}
</>
);
}

View File

@@ -1,3 +1,3 @@
import Room from "./room";
import RoomContainer from "./components/RoomContainer";
export default Room;
export default RoomContainer;

View File

@@ -261,7 +261,11 @@ export default function Room(details: RoomDetails) {
const params = use(details.params);
const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null);
const roomName = parseNonEmptyString(params.roomName);
const roomName = parseNonEmptyString(
params.roomName,
true,
"panic! params.roomName is required",
);
const router = useRouter();
const auth = useAuth();
const status = auth.status;
@@ -308,7 +312,14 @@ export default function Room(details: RoomDetails) {
const handleMeetingSelect = (selectedMeeting: Meeting) => {
router.push(
roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)),
roomMeetingUrl(
roomName,
parseNonEmptyString(
selectedMeeting.id,
true,
"panic! selectedMeeting.id is required",
),
),
);
};

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
export async function GET() {
const health = {
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV,
checks: {
redis: await checkRedis(),
},
};
const allHealthy = Object.values(health.checks).every((check) => check);
return NextResponse.json(health, {
status: allHealthy ? 200 : 503,
});
}
async function checkRedis(): Promise<boolean> {
try {
if (!process.env.KV_URL) {
return false;
}
const { tokenCacheRedis } = await import("../../lib/redisClient");
const testKey = `health:check:${Date.now()}`;
await tokenCacheRedis.setex(testKey, 10, "OK");
const value = await tokenCacheRedis.get(testKey);
await tokenCacheRedis.del(testKey);
return value === "OK";
} catch (error) {
console.error("Redis health check failed:", error);
return false;
}
}

View File

@@ -6,7 +6,10 @@ import ErrorMessage from "./(errors)/errorMessage";
import { RecordingConsentProvider } from "./recordingConsentContext";
import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers";
import { assertExistsAndNonEmptyString } from "./lib/utils";
import { getNextEnvVar } from "./lib/nextBuild";
import { getClientEnv } from "./lib/clientEnv";
export const dynamic = "force-dynamic";
const poppins = Poppins({
subsets: ["latin"],
@@ -21,13 +24,11 @@ export const viewport: Viewport = {
maximumScale: 1,
};
const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString(
process.env.NEXT_PUBLIC_SITE_URL,
"NEXT_PUBLIC_SITE_URL required",
);
const SITE_URL = getNextEnvVar("SITE_URL");
const env = getClientEnv();
export const metadata: Metadata = {
metadataBase: new URL(NEXT_PUBLIC_SITE_URL),
metadataBase: new URL(SITE_URL),
title: {
template: "%s Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
@@ -74,15 +75,16 @@ export default async function RootLayout({
}) {
return (
<html lang="en" className={poppins.className} suppressHydrationWarning>
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
<RecordingConsentProvider>
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
<ErrorProvider>
<ErrorMessage />
<Providers>{children}</Providers>
</ErrorProvider>
</ErrorBoundary>
</RecordingConsentProvider>
<body
className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}
data-env={JSON.stringify(env)}
>
<ErrorBoundary fallback={<p>"something went really wrong"</p>}>
<ErrorProvider>
<ErrorMessage />
<Providers>{children}</Providers>
</ErrorProvider>
</ErrorBoundary>
</body>
</html>
);

View File

@@ -0,0 +1,180 @@
"use client";
import React, { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { WEBSOCKET_URL } from "./apiClient";
import { useAuth } from "./AuthProvider";
import { z } from "zod";
import { invalidateTranscriptLists, TRANSCRIPT_SEARCH_URL } from "./apiHooks";
const UserEvent = z.object({
event: z.string(),
});
type UserEvent = z.TypeOf<typeof UserEvent>;
class UserEventsStore {
private socket: WebSocket | null = null;
private listeners: Set<(event: MessageEvent) => void> = new Set();
private closeTimeoutId: number | null = null;
private isConnecting = false;
ensureConnection(url: string, subprotocols?: string[]) {
if (typeof window === "undefined") return;
if (this.closeTimeoutId !== null) {
clearTimeout(this.closeTimeoutId);
this.closeTimeoutId = null;
}
if (this.isConnecting) return;
if (
this.socket &&
(this.socket.readyState === WebSocket.OPEN ||
this.socket.readyState === WebSocket.CONNECTING)
) {
return;
}
this.isConnecting = true;
const ws = new WebSocket(url, subprotocols || []);
this.socket = ws;
ws.onmessage = (event: MessageEvent) => {
this.listeners.forEach((listener) => {
try {
listener(event);
} catch (err) {
console.error("UserEvents listener error", err);
}
});
};
ws.onopen = () => {
if (this.socket === ws) this.isConnecting = false;
};
ws.onclose = () => {
if (this.socket === ws) {
this.socket = null;
this.isConnecting = false;
}
};
ws.onerror = () => {
if (this.socket === ws) this.isConnecting = false;
};
}
subscribe(listener: (event: MessageEvent) => void): () => void {
this.listeners.add(listener);
if (this.closeTimeoutId !== null) {
clearTimeout(this.closeTimeoutId);
this.closeTimeoutId = null;
}
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.closeTimeoutId = window.setTimeout(() => {
if (this.socket) {
try {
this.socket.close();
} catch (err) {
console.warn("Error closing user events socket", err);
}
}
this.socket = null;
this.closeTimeoutId = null;
}, 1000);
}
};
}
}
const sharedStore = new UserEventsStore();
export function UserEventsProvider({
children,
}: {
children: React.ReactNode;
}) {
const auth = useAuth();
const queryClient = useQueryClient();
const tokenRef = useRef<string | null>(null);
const detachRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Only tear down when the user is truly unauthenticated
if (auth.status === "unauthenticated") {
if (detachRef.current) {
try {
detachRef.current();
} catch (err) {
console.warn("Error detaching UserEvents listener", err);
}
detachRef.current = null;
}
tokenRef.current = null;
return;
}
// During loading/refreshing, keep the existing connection intact
if (auth.status !== "authenticated") {
return;
}
// Authenticated: pin the initial token for the lifetime of this WS connection
if (!tokenRef.current && auth.accessToken) {
tokenRef.current = auth.accessToken;
}
const pinnedToken = tokenRef.current;
const url = `${WEBSOCKET_URL}/v1/events`;
// Ensure a single shared connection
sharedStore.ensureConnection(
url,
pinnedToken ? ["bearer", pinnedToken] : undefined,
);
// Subscribe once; avoid re-subscribing during transient status changes
if (!detachRef.current) {
const onMessage = (event: MessageEvent) => {
try {
const msg = UserEvent.parse(JSON.parse(event.data));
const eventName = msg.event;
const invalidateList = () => invalidateTranscriptLists(queryClient);
switch (eventName) {
case "TRANSCRIPT_CREATED":
case "TRANSCRIPT_DELETED":
case "TRANSCRIPT_STATUS":
case "TRANSCRIPT_FINAL_TITLE":
case "TRANSCRIPT_DURATION":
invalidateList().then(() => {});
break;
default:
// Ignore other content events for list updates
break;
}
} catch (err) {
console.warn("Invalid user event message", event.data);
}
};
const unsubscribe = sharedStore.subscribe(onMessage);
detachRef.current = unsubscribe;
}
}, [auth.status, queryClient]);
// On unmount, detach the listener and clear the pinned token
useEffect(() => {
return () => {
if (detachRef.current) {
try {
detachRef.current();
} catch (err) {
console.warn("Error detaching UserEvents listener on unmount", err);
}
detachRef.current = null;
}
tokenRef.current = null;
};
}, []);
return <>{children}</>;
}

View File

@@ -3,21 +3,19 @@
import createClient from "openapi-fetch";
import type { paths } from "../reflector-api";
import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils";
import { parseNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
import { getSession } from "next-auth/react";
import { assertExtendedToken } from "./types";
import { getClientEnv } from "./clientEnv";
export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(
process.env.NEXT_PUBLIC_API_URL,
"NEXT_PUBLIC_API_URL required",
)
? getClientEnv().API_URL
: "http://localhost";
// TODO decide strict validation or not
export const WEBSOCKET_URL =
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
export const WEBSOCKET_URL = !isBuildPhase
? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250"
: "ws://localhost";
export const client = createClient<paths>({
baseUrl: API_URL,
@@ -44,7 +42,7 @@ client.use({
if (token !== null) {
request.headers.set(
"Authorization",
`Bearer ${parseNonEmptyString(token)}`,
`Bearer ${parseNonEmptyString(token, true, "panic! token is required")}`,
);
}
// XXX Only set Content-Type if not already set (FormData will set its own boundary)

View File

@@ -2,7 +2,7 @@
import { $api } from "./apiClient";
import { useError } from "../(errors)/errorContext";
import { useQueryClient } from "@tanstack/react-query";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import type { components } from "../reflector-api";
import { useAuth } from "./AuthProvider";
@@ -40,6 +40,13 @@ export function useRoomsList(page: number = 1) {
type SourceKind = components["schemas"]["SourceKind"];
export const TRANSCRIPT_SEARCH_URL = "/v1/transcripts/search" as const;
export const invalidateTranscriptLists = (queryClient: QueryClient) =>
queryClient.invalidateQueries({
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
export function useTranscriptsSearch(
q: string = "",
options: {
@@ -51,7 +58,7 @@ export function useTranscriptsSearch(
) {
return $api.useQuery(
"get",
"/v1/transcripts/search",
TRANSCRIPT_SEARCH_URL,
{
params: {
query: {
@@ -76,7 +83,7 @@ export function useTranscriptDelete() {
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: ["get", "/v1/transcripts/search"],
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
},
onError: (error) => {
@@ -613,7 +620,7 @@ export function useTranscriptCreate() {
return $api.useMutation("post", "/v1/transcripts", {
onSuccess: () => {
return queryClient.invalidateQueries({
queryKey: ["get", "/v1/transcripts/search"],
queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
},
onError: (error) => {

View File

@@ -18,26 +18,25 @@ import {
deleteTokenCache,
} from "./redisTokenCache";
import { tokenCacheRedis, redlock } from "./redisClient";
import { isBuildPhase } from "./next";
import { sequenceThrows } from "./errorUtils";
import { featureEnabled } from "./features";
import { getNextEnvVar } from "./nextBuild";
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
const getAuthentikClientId = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_ID,
"AUTHENTIK_CLIENT_ID required",
);
const getAuthentikClientSecret = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_CLIENT_SECRET,
"AUTHENTIK_CLIENT_SECRET required",
);
const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID");
const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET");
const getAuthentikRefreshTokenUrl = () =>
assertExistsAndNonEmptyString(
process.env.AUTHENTIK_REFRESH_TOKEN_URL,
"AUTHENTIK_REFRESH_TOKEN_URL required",
);
getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL");
const getAuthentikIssuer = () => {
const stringUrl = getNextEnvVar("AUTHENTIK_ISSUER");
try {
new URL(stringUrl);
} catch (e) {
throw new Error("AUTHENTIK_ISSUER is not a valid URL: " + stringUrl);
}
return stringUrl;
};
export const authOptions = (): AuthOptions =>
featureEnabled("requireLogin")
@@ -45,16 +44,17 @@ export const authOptions = (): AuthOptions =>
providers: [
AuthentikProvider({
...(() => {
const [clientId, clientSecret] = sequenceThrows(
const [clientId, clientSecret, issuer] = sequenceThrows(
getAuthentikClientId,
getAuthentikClientSecret,
getAuthentikIssuer,
);
return {
clientId,
clientSecret,
issuer,
};
})(),
issuer: process.env.AUTHENTIK_ISSUER,
authorization: {
params: {
scope: "openid email profile offline_access",

91
www/app/lib/clientEnv.ts Normal file
View File

@@ -0,0 +1,91 @@
import {
assertExists,
assertExistsAndNonEmptyString,
NonEmptyString,
parseNonEmptyString,
} from "./utils";
import { isBuildPhase } from "./next";
import { getNextEnvVar } from "./nextBuild";
export const FEATURE_REQUIRE_LOGIN_ENV_NAME = "FEATURE_REQUIRE_LOGIN" as const;
export const FEATURE_PRIVACY_ENV_NAME = "FEATURE_PRIVACY" as const;
export const FEATURE_BROWSE_ENV_NAME = "FEATURE_BROWSE" as const;
export const FEATURE_SEND_TO_ZULIP_ENV_NAME = "FEATURE_SEND_TO_ZULIP" as const;
export const FEATURE_ROOMS_ENV_NAME = "FEATURE_ROOMS" as const;
const FEATURE_ENV_NAMES = [
FEATURE_REQUIRE_LOGIN_ENV_NAME,
FEATURE_PRIVACY_ENV_NAME,
FEATURE_BROWSE_ENV_NAME,
FEATURE_SEND_TO_ZULIP_ENV_NAME,
FEATURE_ROOMS_ENV_NAME,
] as const;
export type FeatureEnvName = (typeof FEATURE_ENV_NAMES)[number];
export type EnvFeaturePartial = {
[key in FeatureEnvName]: boolean | null;
};
// CONTRACT: isomorphic with JSON.stringify
export type ClientEnvCommon = EnvFeaturePartial & {
API_URL: NonEmptyString;
WEBSOCKET_URL: NonEmptyString | null;
};
let clientEnv: ClientEnvCommon | null = null;
export const getClientEnvClient = (): ClientEnvCommon => {
if (typeof window === "undefined") {
throw new Error(
"getClientEnv() called during SSR - this should only be called in browser environment",
);
}
if (clientEnv) return clientEnv;
clientEnv = assertExists(
JSON.parse(
assertExistsAndNonEmptyString(
document.body.dataset.env,
"document.body.dataset.env is missing",
),
),
"document.body.dataset.env is parsed to nullish",
);
return clientEnv!;
};
const parseBooleanString = (str: string | undefined): boolean | null => {
if (str === undefined) return null;
return str === "true";
};
export const getClientEnvServer = (): ClientEnvCommon => {
if (typeof window !== "undefined") {
throw new Error(
"getClientEnv() not called during SSR - this should only be called in server environment",
);
}
if (clientEnv) return clientEnv;
const features = FEATURE_ENV_NAMES.reduce((acc, x) => {
acc[x] = parseBooleanString(process.env[x]);
return acc;
}, {} as EnvFeaturePartial);
if (isBuildPhase) {
return {
API_URL: getNextEnvVar("API_URL"),
WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"),
...features,
};
}
clientEnv = {
API_URL: getNextEnvVar("API_URL"),
WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"),
...features,
};
return clientEnv;
};
export const getClientEnv =
typeof window === "undefined" ? getClientEnvServer : getClientEnvClient;

View File

@@ -1,3 +1,13 @@
import {
FEATURE_BROWSE_ENV_NAME,
FEATURE_PRIVACY_ENV_NAME,
FEATURE_REQUIRE_LOGIN_ENV_NAME,
FEATURE_ROOMS_ENV_NAME,
FEATURE_SEND_TO_ZULIP_ENV_NAME,
FeatureEnvName,
getClientEnv,
} from "./clientEnv";
export const FEATURES = [
"requireLogin",
"privacy",
@@ -18,38 +28,30 @@ export const DEFAULT_FEATURES: Features = {
rooms: true,
} as const;
function parseBooleanEnv(
value: string | undefined,
defaultValue: boolean = false,
): boolean {
if (!value) return defaultValue;
return value.toLowerCase() === "true";
}
export const ENV_TO_FEATURE: {
[k in FeatureEnvName]: FeatureName;
} = {
FEATURE_REQUIRE_LOGIN: "requireLogin",
FEATURE_PRIVACY: "privacy",
FEATURE_BROWSE: "browse",
FEATURE_SEND_TO_ZULIP: "sendToZulip",
FEATURE_ROOMS: "rooms",
} as const;
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
const features: Features = {
requireLogin: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
DEFAULT_FEATURES.requireLogin,
),
privacy: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
DEFAULT_FEATURES.privacy,
),
browse: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_BROWSE,
DEFAULT_FEATURES.browse,
),
sendToZulip: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
DEFAULT_FEATURES.sendToZulip,
),
rooms: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_ROOMS,
DEFAULT_FEATURES.rooms,
),
export const FEATURE_TO_ENV: {
[k in FeatureName]: FeatureEnvName;
} = {
requireLogin: "FEATURE_REQUIRE_LOGIN",
privacy: "FEATURE_PRIVACY",
browse: "FEATURE_BROWSE",
sendToZulip: "FEATURE_SEND_TO_ZULIP",
rooms: "FEATURE_ROOMS",
};
const features = getClientEnv();
export const featureEnabled = (featureName: FeatureName): boolean => {
return features[featureName];
const isSet = features[FEATURE_TO_ENV[featureName]];
if (isSet === null) return DEFAULT_FEATURES[featureName];
return isSet;
};

17
www/app/lib/nextBuild.ts Normal file
View File

@@ -0,0 +1,17 @@
import { isBuildPhase } from "./next";
import { assertExistsAndNonEmptyString, NonEmptyString } from "./utils";
const _getNextEnvVar = (name: string, e?: string): NonEmptyString =>
isBuildPhase
? (() => {
throw new Error(
"panic! getNextEnvVar called during build phase; we don't support build envs",
);
})()
: assertExistsAndNonEmptyString(
process.env[name],
`${name} is required; ${e}`,
);
export const getNextEnvVar = (name: string, e?: string): NonEmptyString =>
_getNextEnvVar(name, e);

View File

@@ -1,7 +1,3 @@
export function isDevelopment() {
return process.env.NEXT_PUBLIC_ENV === "development";
}
// Function to calculate WCAG contrast ratio
export const getContrastRatio = (
foreground: [number, number, number],
@@ -145,8 +141,15 @@ export const parseMaybeNonEmptyString = (
s = trim ? s.trim() : s;
return s.length > 0 ? (s as NonEmptyString) : null;
};
export const parseNonEmptyString = (s: string, trim = true): NonEmptyString =>
assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string");
export const parseNonEmptyString = (
s: string,
trim = true,
e?: string,
): NonEmptyString =>
assertExists(
parseMaybeNonEmptyString(s, trim),
"Expected non-empty string" + (e ? `: ${e}` : ""),
);
export const assertExists = <T>(
value: T | null | undefined,
@@ -173,4 +176,8 @@ export const assertExistsAndNonEmptyString = (
value: string | null | undefined,
err?: string,
): NonEmptyString =>
parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
parseNonEmptyString(
assertExists(value, err || "Expected non-empty string"),
true,
err,
);

View File

@@ -10,6 +10,8 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./lib/queryClient";
import { AuthProvider } from "./lib/AuthProvider";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
import { RecordingConsentProvider } from "./recordingConsentContext";
import { UserEventsProvider } from "./lib/UserEventsProvider";
const WherebyProvider = dynamic(
() =>
@@ -26,10 +28,14 @@ export function Providers({ children }: { children: React.ReactNode }) {
<SessionProviderNextAuth>
<AuthProvider>
<ChakraProvider value={system}>
<WherebyProvider>
{children}
<Toaster />
</WherebyProvider>
<RecordingConsentProvider>
<UserEventsProvider>
<WherebyProvider>
{children}
<Toaster />
</WherebyProvider>
</UserEventsProvider>
</RecordingConsentProvider>
</ChakraProvider>
</AuthProvider>
</SessionProviderNextAuth>

View File

@@ -4,6 +4,23 @@
*/
export interface paths {
"/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Health */
get: operations["health"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/metrics": {
parameters: {
query?: never;
@@ -644,6 +661,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/webhook": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Webhook
* @description Handle Daily webhook events.
*/
post: operations["v1_webhook"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -750,6 +787,8 @@ export interface components {
* @default false
*/
ics_enabled: boolean;
/** Platform */
platform?: ("whereby" | "daily") | null;
};
/** CreateRoomMeeting */
CreateRoomMeeting: {
@@ -775,6 +814,22 @@ export interface components {
target_language: string;
source_kind?: components["schemas"]["SourceKind"] | null;
};
/**
* DailyWebhookEvent
* @description Daily webhook event structure.
*/
DailyWebhookEvent: {
/** Type */
type: string;
/** Id */
id: string;
/** Ts */
ts: number;
/** Data */
data: {
[key: string]: unknown;
};
};
/** DeletionStatus */
DeletionStatus: {
/** Status */
@@ -1091,6 +1146,12 @@ export interface components {
calendar_metadata?: {
[key: string]: unknown;
} | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
};
/** MeetingConsentRequest */
MeetingConsentRequest: {
@@ -1177,6 +1238,12 @@ export interface components {
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
};
/** RoomDetails */
RoomDetails: {
@@ -1223,6 +1290,12 @@ export interface components {
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
/** Webhook Url */
webhook_url: string | null;
/** Webhook Secret */
@@ -1403,6 +1476,8 @@ export interface components {
ics_fetch_interval?: number | null;
/** Ics Enabled */
ics_enabled?: boolean | null;
/** Platform */
platform?: ("whereby" | "daily") | null;
};
/** UpdateTranscript */
UpdateTranscript: {
@@ -1509,6 +1584,26 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
health: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
metrics: {
parameters: {
query?: never;
@@ -2983,4 +3078,37 @@ export interface operations {
};
};
};
v1_webhook: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DailyWebhookEvent"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}

View File

@@ -5,6 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"build-production": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"format": "prettier --write .",
@@ -13,6 +14,7 @@
},
"dependencies": {
"@chakra-ui/react": "^3.24.2",
"@daily-co/daily-js": "^0.84.0",
"@emotion/react": "^11.14.0",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",

96
www/pnpm-lock.yaml generated
View File

@@ -10,6 +10,9 @@ importers:
"@chakra-ui/react":
specifier: ^3.24.2
version: 3.24.2(@emotion/react@11.14.0(@types/react@18.2.20)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
"@daily-co/daily-js":
specifier: ^0.84.0
version: 0.84.0
"@emotion/react":
specifier: ^11.14.0
version: 11.14.0(@types/react@18.2.20)(react@18.3.1)
@@ -487,6 +490,13 @@ packages:
}
engines: { node: ">=12" }
"@daily-co/daily-js@0.84.0":
resolution:
{
integrity: sha512-/ynXrMDDkRXhLlHxiFNf9QU5yw4ZGPr56wNARgja/Tiid71UIniundTavCNF5cMb2I1vNoMh7oEJ/q8stg/V7g==,
}
engines: { node: ">=10.0.0" }
"@emnapi/core@1.4.5":
resolution:
{
@@ -2293,6 +2303,13 @@ packages:
}
engines: { node: ">=18" }
"@sentry-internal/browser-utils@8.55.0":
resolution:
{
integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==,
}
engines: { node: ">=14.18" }
"@sentry-internal/feedback@10.11.0":
resolution:
{
@@ -2300,6 +2317,13 @@ packages:
}
engines: { node: ">=18" }
"@sentry-internal/feedback@8.55.0":
resolution:
{
integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==,
}
engines: { node: ">=14.18" }
"@sentry-internal/replay-canvas@10.11.0":
resolution:
{
@@ -2307,6 +2331,13 @@ packages:
}
engines: { node: ">=18" }
"@sentry-internal/replay-canvas@8.55.0":
resolution:
{
integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==,
}
engines: { node: ">=14.18" }
"@sentry-internal/replay@10.11.0":
resolution:
{
@@ -2314,6 +2345,13 @@ packages:
}
engines: { node: ">=18" }
"@sentry-internal/replay@8.55.0":
resolution:
{
integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==,
}
engines: { node: ">=14.18" }
"@sentry/babel-plugin-component-annotate@4.3.0":
resolution:
{
@@ -2328,6 +2366,13 @@ packages:
}
engines: { node: ">=18" }
"@sentry/browser@8.55.0":
resolution:
{
integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==,
}
engines: { node: ">=14.18" }
"@sentry/bundler-plugin-core@4.3.0":
resolution:
{
@@ -2421,6 +2466,13 @@ packages:
}
engines: { node: ">=18" }
"@sentry/core@8.55.0":
resolution:
{
integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==,
}
engines: { node: ">=14.18" }
"@sentry/nextjs@10.11.0":
resolution:
{
@@ -4029,6 +4081,12 @@ packages:
}
engines: { node: ">=8" }
bowser@2.12.1:
resolution:
{
integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==,
}
brace-expansion@1.1.12:
resolution:
{
@@ -9288,6 +9346,14 @@ snapshots:
"@jridgewell/trace-mapping": 0.3.9
optional: true
"@daily-co/daily-js@0.84.0":
dependencies:
"@babel/runtime": 7.28.2
"@sentry/browser": 8.55.0
bowser: 2.12.1
dequal: 2.0.3
events: 3.3.0
"@emnapi/core@1.4.5":
dependencies:
"@emnapi/wasi-threads": 1.0.4
@@ -10506,20 +10572,38 @@ snapshots:
dependencies:
"@sentry/core": 10.11.0
"@sentry-internal/browser-utils@8.55.0":
dependencies:
"@sentry/core": 8.55.0
"@sentry-internal/feedback@10.11.0":
dependencies:
"@sentry/core": 10.11.0
"@sentry-internal/feedback@8.55.0":
dependencies:
"@sentry/core": 8.55.0
"@sentry-internal/replay-canvas@10.11.0":
dependencies:
"@sentry-internal/replay": 10.11.0
"@sentry/core": 10.11.0
"@sentry-internal/replay-canvas@8.55.0":
dependencies:
"@sentry-internal/replay": 8.55.0
"@sentry/core": 8.55.0
"@sentry-internal/replay@10.11.0":
dependencies:
"@sentry-internal/browser-utils": 10.11.0
"@sentry/core": 10.11.0
"@sentry-internal/replay@8.55.0":
dependencies:
"@sentry-internal/browser-utils": 8.55.0
"@sentry/core": 8.55.0
"@sentry/babel-plugin-component-annotate@4.3.0": {}
"@sentry/browser@10.11.0":
@@ -10530,6 +10614,14 @@ snapshots:
"@sentry-internal/replay-canvas": 10.11.0
"@sentry/core": 10.11.0
"@sentry/browser@8.55.0":
dependencies:
"@sentry-internal/browser-utils": 8.55.0
"@sentry-internal/feedback": 8.55.0
"@sentry-internal/replay": 8.55.0
"@sentry-internal/replay-canvas": 8.55.0
"@sentry/core": 8.55.0
"@sentry/bundler-plugin-core@4.3.0":
dependencies:
"@babel/core": 7.28.3
@@ -10590,6 +10682,8 @@ snapshots:
"@sentry/core@10.11.0": {}
"@sentry/core@8.55.0": {}
"@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)":
dependencies:
"@opentelemetry/api": 1.9.0
@@ -11967,6 +12061,8 @@ snapshots:
binary-extensions@2.3.0: {}
bowser@2.12.1: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2