mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Compare commits
21 Commits
main
...
igor/daili
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf73c8199 | ||
| d82abf65ba | |||
|
|
7d239fe380 | ||
| acb6e90f28 | |||
|
|
f844b9fc1f | ||
| 96f05020cc | |||
| fc79ff3114 | |||
|
|
3641e2e599 | ||
| c23518d2e3 | |||
| 23edffe2a2 | |||
| e59770ecc9 | |||
| 6301f2afa6 | |||
| 9ac7f0e8e2 | |||
|
|
0a84a9351a | ||
|
|
ca22084845 | ||
|
|
f945f84be9 | ||
|
|
4c523c8eec | ||
|
|
0fcf8b6875 | ||
|
|
446cb748ae | ||
|
|
3e1339a8ea | ||
|
|
807819bb2f |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
345
CODER_BRIEFING.md
Normal file
345
CODER_BRIEFING.md
Normal 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
489
IMPLEMENTATION_GUIDE.md
Normal 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.
|
||||
613
server/DAILYCO_TEST.md
Normal file
613
server/DAILYCO_TEST.md
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -94,6 +95,7 @@ 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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
84
server/reflector/pipelines/MULTITRACK_FIX_SUMMARY.md
Normal file
84
server/reflector/pipelines/MULTITRACK_FIX_SUMMARY.md
Normal 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`
|
||||
510
server/reflector/pipelines/main_multitrack_pipeline.backup.py
Normal file
510
server/reflector/pipelines/main_multitrack_pipeline.backup.py
Normal 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()
|
||||
654
server/reflector/pipelines/main_multitrack_pipeline.py
Normal file
654
server/reflector/pipelines/main_multitrack_pipeline.py
Normal 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()
|
||||
629
server/reflector/pipelines/main_multitrack_pipeline_fixed.py
Normal file
629
server/reflector/pipelines/main_multitrack_pipeline_fixed.py
Normal 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()
|
||||
9
server/reflector/platform_types.py
Normal file
9
server/reflector/platform_types.py
Normal 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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
18
server/reflector/video_platforms/__init__.py
Normal file
18
server/reflector/video_platforms/__init__.py
Normal 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",
|
||||
]
|
||||
60
server/reflector/video_platforms/base.py
Normal file
60
server/reflector/video_platforms/base.py
Normal 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}
|
||||
178
server/reflector/video_platforms/daily.py
Normal file
178
server/reflector/video_platforms/daily.py
Normal 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"]
|
||||
80
server/reflector/video_platforms/factory.py
Normal file
80
server/reflector/video_platforms/factory.py
Normal 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
|
||||
49
server/reflector/video_platforms/models.py
Normal file
49
server/reflector/video_platforms/models.py
Normal 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
|
||||
39
server/reflector/video_platforms/registry.py
Normal file
39
server/reflector/video_platforms/registry.py
Normal 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()
|
||||
140
server/reflector/video_platforms/whereby.py
Normal file
140
server/reflector/video_platforms/whereby.py
Normal 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
|
||||
235
server/reflector/views/daily.py
Normal file
235
server/reflector/views/daily.py
Normal 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",
|
||||
)
|
||||
@@ -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(
|
||||
@@ -201,6 +214,7 @@ async def rooms_get(
|
||||
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
|
||||
|
||||
|
||||
@@ -214,17 +228,16 @@ 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)
|
||||
|
||||
|
||||
@@ -251,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -268,6 +282,7 @@ async def rooms_update(
|
||||
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
|
||||
|
||||
|
||||
@@ -315,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)
|
||||
@@ -336,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 = ""
|
||||
|
||||
@@ -490,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 = ""
|
||||
@@ -520,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 = ""
|
||||
|
||||
@@ -555,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 = ""
|
||||
|
||||
|
||||
179
server/reflector/worker/daily_stub_data.py
Normal file
179
server/reflector/worker/daily_stub_data.py
Normal 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,
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
@@ -238,6 +333,239 @@ async def process_meetings():
|
||||
)
|
||||
|
||||
|
||||
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():
|
||||
|
||||
65
server/reprocess_transcript.py
Normal file
65
server/reprocess_transcript.py
Normal 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}")
|
||||
72
server/scripts/recreate_daily_webhook.py
Normal file
72
server/scripts/recreate_daily_webhook.py
Normal 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])))
|
||||
124
server/test_multitrack_ffmpeg.py
Normal file
124
server/test_multitrack_ffmpeg.py
Normal 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!"
|
||||
)
|
||||
162
server/test_multitrack_ffmpeg_local.py
Normal file
162
server/test_multitrack_ffmpeg_local.py
Normal 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!"
|
||||
)
|
||||
66
server/test_s3_multitrack.py
Normal file
66
server/test_s3_multitrack.py
Normal 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}")
|
||||
@@ -6,6 +6,16 @@ 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
|
||||
|
||||
0
server/tests/mocks/__init__.py
Normal file
0
server/tests/mocks/__init__.py
Normal file
111
server/tests/mocks/mock_platform.py
Normal file
111
server/tests/mocks/mock_platform.py
Normal 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()
|
||||
23
server/trigger_reprocess.py
Normal file
23
server/trigger_reprocess.py
Normal 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"
|
||||
)
|
||||
237
www/app/[roomName]/components/DailyRoom.tsx
Normal file
237
www/app/[roomName]/components/DailyRoom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
www/app/[roomName]/components/RoomContainer.tsx
Normal file
214
www/app/[roomName]/components/RoomContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
271
www/app/[roomName]/components/WherebyRoom.tsx
Normal file
271
www/app/[roomName]/components/WherebyRoom.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import Room from "./room";
|
||||
import RoomContainer from "./components/RoomContainer";
|
||||
|
||||
export default Room;
|
||||
export default RoomContainer;
|
||||
|
||||
128
www/app/reflector-api.d.ts
vendored
128
www/app/reflector-api.d.ts
vendored
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,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
96
www/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user