feat: make video recording optional, deleting video tracks (#954)

* feat: make video recording optional, deleting video tracks
This commit is contained in:
Juan Diego García
2026-04-08 17:05:50 -05:00
committed by GitHub
parent 5f0c5635eb
commit ee8db36f2c
12 changed files with 202 additions and 9 deletions

View File

@@ -0,0 +1,43 @@
"""add store_video to room and meeting
Revision ID: c1d2e3f4a5b6
Revises: b4c7e8f9a012
Create Date: 2026-04-08 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c1d2e3f4a5b6"
down_revision: Union[str, None] = "b4c7e8f9a012"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"room",
sa.Column(
"store_video",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
op.add_column(
"meeting",
sa.Column(
"store_video",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column("meeting", "store_video")
op.drop_column("room", "store_video")

View File

@@ -69,6 +69,7 @@ meetings = sa.Table(
sa.Column("daily_composed_video_duration", sa.Integer, nullable=True),
# Email recipients for transcript notification
sa.Column("email_recipients", JSONB, nullable=True),
sa.Column("store_video", sa.Boolean, nullable=False, server_default=sa.false()),
sa.Index("idx_meeting_room_id", "room_id"),
sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
)
@@ -122,6 +123,7 @@ class Meeting(BaseModel):
# Email recipients for transcript notification
# Each entry is {"email": str, "include_link": bool} or a legacy plain str
email_recipients: list[dict | str] | None = None
store_video: bool = False
class MeetingController:
@@ -152,6 +154,7 @@ class MeetingController:
calendar_event_id=calendar_event_id,
calendar_metadata=calendar_metadata,
platform=room.platform,
store_video=room.store_video,
)
query = meetings.insert().values(**meeting.model_dump())
await get_database().execute(query)

View File

@@ -64,6 +64,9 @@ rooms = sqlalchemy.Table(
server_default=sqlalchemy.sql.false(),
),
sqlalchemy.Column("email_transcript_to", sqlalchemy.String, nullable=True),
sqlalchemy.Column(
"store_video", sqlalchemy.Boolean, nullable=False, server_default=false()
),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -94,6 +97,7 @@ class Room(BaseModel):
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
skip_consent: bool = False
email_transcript_to: str | None = None
store_video: bool = False
class RoomController:
@@ -150,6 +154,7 @@ class RoomController:
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
skip_consent: bool = False,
email_transcript_to: str | None = None,
store_video: bool = False,
):
"""
Add a new room
@@ -176,6 +181,7 @@ class RoomController:
"platform": platform,
"skip_consent": skip_consent,
"email_transcript_to": email_transcript_to,
"store_video": store_video,
}
room = Room(**room_data)

View File

@@ -10,6 +10,7 @@ from reflector.hatchet.client import HatchetClientManager
from reflector.hatchet.workflows.daily_multitrack_pipeline import (
daily_multitrack_pipeline,
)
from reflector.hatchet.workflows.failed_runs_monitor import failed_runs_monitor
from reflector.hatchet.workflows.file_pipeline import file_pipeline
from reflector.hatchet.workflows.live_post_pipeline import live_post_pipeline
from reflector.hatchet.workflows.subject_processing import subject_workflow
@@ -54,10 +55,6 @@ def main():
]
)
if _zulip_dag_enabled:
from reflector.hatchet.workflows.failed_runs_monitor import ( # noqa: PLC0415
failed_runs_monitor,
)
workflows.append(failed_runs_monitor)
logger.info(
"FailedRunsMonitor cron enabled",

View File

@@ -219,6 +219,32 @@ async def _handle_recording_ready(event: RecordingReadyEvent):
track_keys = [t.s3Key for t in tracks if t.type == "audio"]
# Delete video tracks when store_video is disabled (same pattern as LiveKit).
# Only delete if we have a meeting AND store_video is explicitly false.
# If no meeting found, leave files alone (can't confirm user intent).
video_track_keys = [t.s3Key for t in tracks if t.type == "video"]
if video_track_keys:
meeting = await meetings_controller.get_by_room_name(room_name)
if meeting is not None and not meeting.store_video:
from reflector.storage import get_source_storage
storage = get_source_storage("daily")
for video_key in video_track_keys:
try:
await storage.delete_file(video_key)
logger.info(
"Deleted video track from raw-tracks recording",
s3_key=video_key,
room_name=room_name,
)
except Exception as e:
# Non-critical — pipeline filters these out anyway
logger.warning(
"Failed to delete video track from raw-tracks recording",
s3_key=video_key,
error=str(e),
)
logger.info(
"Raw-tracks recording queuing processing",
recording_id=recording_id,

View File

@@ -45,6 +45,7 @@ class Room(BaseModel):
platform: Platform
skip_consent: bool = False
email_transcript_to: str | None = None
store_video: bool = False
class RoomDetails(Room):
@@ -75,6 +76,7 @@ class Meeting(BaseModel):
platform: Platform
daily_composed_video_s3_key: str | None = None
daily_composed_video_duration: int | None = None
store_video: bool = False
class CreateRoom(BaseModel):
@@ -95,6 +97,7 @@ class CreateRoom(BaseModel):
platform: Platform
skip_consent: bool = False
email_transcript_to: str | None = None
store_video: bool = False
class UpdateRoom(BaseModel):
@@ -115,6 +118,7 @@ class UpdateRoom(BaseModel):
platform: Optional[Platform] = None
skip_consent: Optional[bool] = None
email_transcript_to: Optional[str] = None
store_video: Optional[bool] = None
class CreateRoomMeeting(BaseModel):
@@ -257,6 +261,7 @@ async def rooms_create(
platform=room.platform,
skip_consent=room.skip_consent,
email_transcript_to=room.email_transcript_to,
store_video=room.store_video,
)
@@ -325,6 +330,7 @@ async def rooms_create_meeting(
and meeting.recording_type == room.recording_type
and meeting.recording_trigger == room.recording_trigger
and meeting.platform == room.platform
and meeting.store_video == room.store_video
)
if not settings_match:
logger.info(

View File

@@ -30,6 +30,8 @@ def build_beat_schedule(
whereby_api_key=None,
aws_process_recording_queue_url=None,
daily_api_key=None,
livekit_api_key=None,
livekit_url=None,
public_mode=False,
public_data_retention_days=None,
healthcheck_url=None,
@@ -83,7 +85,7 @@ def build_beat_schedule(
else:
logger.info("Daily.co beat tasks disabled (no DAILY_API_KEY)")
_livekit_enabled = bool(settings.LIVEKIT_API_KEY and settings.LIVEKIT_URL)
_livekit_enabled = bool(livekit_api_key and livekit_url)
if _livekit_enabled:
beat_schedule["process_livekit_ended_meetings"] = {
"task": "reflector.worker.process.process_livekit_ended_meetings",
@@ -175,6 +177,8 @@ else:
whereby_api_key=settings.WHEREBY_API_KEY,
aws_process_recording_queue_url=settings.AWS_PROCESS_RECORDING_QUEUE_URL,
daily_api_key=settings.DAILY_API_KEY,
livekit_api_key=settings.LIVEKIT_API_KEY,
livekit_url=settings.LIVEKIT_URL,
public_mode=settings.PUBLIC_MODE,
public_data_retention_days=settings.PUBLIC_DATA_RETENTION_DAYS,
healthcheck_url=settings.HEALTHCHECK_URL,

View File

@@ -562,6 +562,15 @@ async def store_cloud_recording(
)
return False
if not meeting.store_video:
logger.info(
f"Cloud recording ({source}): skipped, store_video=false",
recording_id=recording_id,
room_name=room_name,
meeting_id=meeting.id,
)
return False
success = await meetings_controller.set_cloud_recording_if_missing(
meeting_id=meeting.id,
s3_key=s3_key,

View File

@@ -32,6 +32,10 @@ DAILY_TASKS = {
"trigger_daily_reconciliation",
"reprocess_failed_daily_recordings",
}
LIVEKIT_TASKS = {
"process_livekit_ended_meetings",
"reprocess_failed_livekit_recordings",
}
PLATFORM_TASKS = {
"process_meetings",
"sync_all_ics_calendars",
@@ -47,6 +51,7 @@ class TestNoPlatformConfigured:
task_names = set(schedule.keys())
assert not task_names & WHEREBY_TASKS
assert not task_names & DAILY_TASKS
assert not task_names & LIVEKIT_TASKS
assert not task_names & PLATFORM_TASKS
def test_only_healthcheck_disabled_warning(self):
@@ -72,6 +77,7 @@ class TestWherebyOnly:
assert WHEREBY_TASKS <= task_names
assert PLATFORM_TASKS <= task_names
assert not task_names & DAILY_TASKS
assert not task_names & LIVEKIT_TASKS
def test_whereby_sqs_url(self):
schedule = build_beat_schedule(
@@ -81,6 +87,7 @@ class TestWherebyOnly:
assert WHEREBY_TASKS <= task_names
assert PLATFORM_TASKS <= task_names
assert not task_names & DAILY_TASKS
assert not task_names & LIVEKIT_TASKS
def test_whereby_task_count(self):
schedule = build_beat_schedule(whereby_api_key="test-key")
@@ -97,6 +104,7 @@ class TestDailyOnly:
assert DAILY_TASKS <= task_names
assert PLATFORM_TASKS <= task_names
assert not task_names & WHEREBY_TASKS
assert not task_names & LIVEKIT_TASKS
def test_daily_task_count(self):
schedule = build_beat_schedule(daily_api_key="test-daily-key")
@@ -104,6 +112,33 @@ class TestDailyOnly:
assert len(schedule) == 6
class TestLiveKitOnly:
"""When only LiveKit is configured."""
def test_livekit_keys(self):
schedule = build_beat_schedule(
livekit_api_key="test-lk-key", livekit_url="ws://livekit:7880"
)
task_names = set(schedule.keys())
assert LIVEKIT_TASKS <= task_names
assert PLATFORM_TASKS <= task_names
assert not task_names & WHEREBY_TASKS
assert not task_names & DAILY_TASKS
def test_livekit_task_count(self):
schedule = build_beat_schedule(
livekit_api_key="test-lk-key", livekit_url="ws://livekit:7880"
)
# LiveKit (2) + Platform (3) = 5
assert len(schedule) == 5
def test_livekit_needs_both_key_and_url(self):
schedule_key_only = build_beat_schedule(livekit_api_key="test-lk-key")
schedule_url_only = build_beat_schedule(livekit_url="ws://livekit:7880")
assert not set(schedule_key_only.keys()) & LIVEKIT_TASKS
assert not set(schedule_url_only.keys()) & LIVEKIT_TASKS
class TestBothPlatforms:
"""When both Whereby and Daily.co are configured."""