feat: delete recording with transcript (#547)

* Delete recording with transcript

* Delete confirmation dialog

* Use aws storage abstraction for recording deletion

* Test recording deleted with transcript

* Use get transcript storage

* Fix the test

* Add env vars for recording storage
This commit is contained in:
2025-08-14 20:45:30 +02:00
committed by GitHub
parent 9eab952c63
commit b9d891d342
9 changed files with 182 additions and 42 deletions

View File

@@ -53,5 +53,9 @@ class RecordingController:
result = await get_database().fetch_one(query) result = await get_database().fetch_one(query)
return Recording(**result) if result else None return Recording(**result) if result else None
async def remove_by_id(self, id: str) -> None:
query = recordings.delete().where(recordings.c.id == id)
await get_database().execute(query)
recordings_controller = RecordingController() recordings_controller = RecordingController()

View File

@@ -16,11 +16,12 @@ from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.sql import false, or_ from sqlalchemy.sql import false, or_
from reflector.db import get_database, metadata from reflector.db import get_database, metadata
from reflector.db.recordings import recordings_controller
from reflector.db.rooms import rooms from reflector.db.rooms import rooms
from reflector.db.utils import is_postgresql from reflector.db.utils import is_postgresql
from reflector.processors.types import Word as ProcessorWord from reflector.processors.types import Word as ProcessorWord
from reflector.settings import settings from reflector.settings import settings
from reflector.storage import get_transcripts_storage from reflector.storage import get_transcripts_storage, get_recordings_storage
from reflector.utils import generate_uuid4 from reflector.utils import generate_uuid4
from reflector.utils.webvtt import topics_to_webvtt from reflector.utils.webvtt import topics_to_webvtt
@@ -593,7 +594,39 @@ class TranscriptController:
return return
if user_id is not None and transcript.user_id != user_id: if user_id is not None and transcript.user_id != user_id:
return return
if transcript.audio_location == "storage" and not transcript.audio_deleted:
try:
await get_transcripts_storage().delete_file(
transcript.storage_audio_path
)
except Exception as e:
logger.warning(
"Failed to delete transcript audio from storage",
error=str(e),
transcript_id=transcript.id,
)
transcript.unlink() transcript.unlink()
if transcript.recording_id:
try:
recording = await recordings_controller.get_by_id(
transcript.recording_id
)
if recording:
try:
await get_recordings_storage().delete_file(recording.object_key)
except Exception as e:
logger.warning(
"Failed to delete recording object from S3",
error=str(e),
recording_id=transcript.recording_id,
)
await recordings_controller.remove_by_id(transcript.recording_id)
except Exception as e:
logger.warning(
"Failed to delete recording row",
error=str(e),
recording_id=transcript.recording_id,
)
query = transcripts.delete().where(transcripts.c.id == transcript_id) query = transcripts.delete().where(transcripts.c.id == transcript_id)
await get_database().execute(query) await get_database().execute(query)

View File

@@ -39,6 +39,15 @@ class Settings(BaseSettings):
TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
# Recording storage
RECORDING_STORAGE_BACKEND: str | None = None
# Recording storage configuration for AWS
RECORDING_STORAGE_AWS_BUCKET_NAME: str = "recording-bucket"
RECORDING_STORAGE_AWS_REGION: str = "us-east-1"
RECORDING_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
RECORDING_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
# Translate into the target language # Translate into the target language
TRANSLATION_BACKEND: str = "passthrough" TRANSLATION_BACKEND: str = "passthrough"
TRANSLATE_URL: str | None = None TRANSLATE_URL: str | None = None
@@ -104,7 +113,6 @@ class Settings(BaseSettings):
WHEREBY_API_URL: str = "https://api.whereby.dev/v1" WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
WHEREBY_API_KEY: str | None = None WHEREBY_API_KEY: str | None = None
WHEREBY_WEBHOOK_SECRET: str | None = None WHEREBY_WEBHOOK_SECRET: str | None = None
AWS_WHEREBY_S3_BUCKET: str | None = None
AWS_WHEREBY_ACCESS_KEY_ID: str | None = None AWS_WHEREBY_ACCESS_KEY_ID: str | None = None
AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None

View File

@@ -1,10 +1,16 @@
from .base import Storage # noqa from .base import Storage # noqa
from reflector.settings import settings
def get_transcripts_storage() -> Storage: def get_transcripts_storage() -> Storage:
from reflector.settings import settings
return Storage.get_instance( return Storage.get_instance(
name=settings.TRANSCRIPT_STORAGE_BACKEND, name=settings.TRANSCRIPT_STORAGE_BACKEND,
settings_prefix="TRANSCRIPT_STORAGE_", settings_prefix="TRANSCRIPT_STORAGE_",
) )
def get_recordings_storage() -> Storage:
return Storage.get_instance(
name=settings.RECORDING_STORAGE_BACKEND,
settings_prefix="RECORDING_STORAGE_",
)

View File

@@ -23,7 +23,7 @@ async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
"type": room.recording_type, "type": room.recording_type,
"destination": { "destination": {
"provider": "s3", "provider": "s3",
"bucket": settings.AWS_WHEREBY_S3_BUCKET, "bucket": settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
"accessKeyId": settings.AWS_WHEREBY_ACCESS_KEY_ID, "accessKeyId": settings.AWS_WHEREBY_ACCESS_KEY_ID,
"accessKeySecret": settings.AWS_WHEREBY_ACCESS_KEY_SECRET, "accessKeySecret": settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
"fileFormat": "mp4", "fileFormat": "mp4",

View File

@@ -185,7 +185,7 @@ async def reprocess_failed_recordings():
reprocessed_count = 0 reprocessed_count = 0
try: try:
paginator = s3.get_paginator("list_objects_v2") paginator = s3.get_paginator("list_objects_v2")
bucket_name = settings.AWS_WHEREBY_S3_BUCKET bucket_name = settings.RECORDING_STORAGE_AWS_BUCKET_NAME
pages = paginator.paginate(Bucket=bucket_name) pages = paginator.paginate(Bucket=bucket_name)
for page in pages: for page in pages:

View File

@@ -0,0 +1,34 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
import pytest
from reflector.db.recordings import Recording, recordings_controller
from reflector.db.transcripts import SourceKind, transcripts_controller
@pytest.mark.asyncio
async def test_recording_deleted_with_transcript():
recording = await recordings_controller.create(
Recording(
bucket_name="test-bucket",
object_key="recording.mp4",
recorded_at=datetime.now(timezone.utc),
)
)
transcript = await transcripts_controller.add(
name="Test Transcript",
source_kind=SourceKind.ROOM,
recording_id=recording.id,
)
with patch("reflector.db.transcripts.get_recordings_storage") as mock_get_storage:
storage_instance = mock_get_storage.return_value
storage_instance.delete_file = AsyncMock()
await transcripts_controller.remove_by_id(transcript.id)
storage_instance.delete_file.assert_awaited_once_with(recording.object_key)
assert await recordings_controller.get_by_id(recording.id) is None
assert await transcripts_controller.get_by_id(transcript.id) is None

View File

@@ -1,12 +1,15 @@
import React from "react"; import React from "react";
import { Button } from "@chakra-ui/react"; import { Button, Dialog, Text } from "@chakra-ui/react";
// import { Dialog } from "@chakra-ui/react";
interface DeleteTranscriptDialogProps { interface DeleteTranscriptDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirm: () => void; onConfirm: () => void;
cancelRef: React.RefObject<any>; cancelRef: React.RefObject<any>;
isLoading?: boolean;
title?: string;
date?: string;
source?: string;
} }
export default function DeleteTranscriptDialog({ export default function DeleteTranscriptDialog({
@@ -14,35 +17,65 @@ export default function DeleteTranscriptDialog({
onClose, onClose,
onConfirm, onConfirm,
cancelRef, cancelRef,
isLoading,
title,
date,
source,
}: DeleteTranscriptDialogProps) { }: DeleteTranscriptDialogProps) {
// Temporarily return null to fix import issues return (
return null;
/* return (
<Dialog.Root <Dialog.Root
open={isOpen} open={isOpen}
onOpenChange={(e) => !e.open && onClose()} onOpenChange={(e) => {
if (!e.open) onClose();
}}
initialFocusEl={() => cancelRef.current} initialFocusEl={() => cancelRef.current}
> >
<Dialog.Backdrop /> <Dialog.Backdrop />
<Dialog.Positioner> <Dialog.Positioner>
<Dialog.Content> <Dialog.Content>
<Dialog.Header fontSize="lg" fontWeight="bold"> <Dialog.Header fontSize="lg" fontWeight="bold">
Delete Transcript Delete transcript
</Dialog.Header> </Dialog.Header>
<Dialog.Body> <Dialog.Body>
Are you sure? You can't undo this action afterwards. Are you sure you want to delete this transcript? This action cannot
be undone.
{title && (
<Text mt={3} fontWeight="600">
{title}
</Text>
)}
{date && (
<Text color="gray.600" fontSize="sm">
Date: {date}
</Text>
)}
{source && (
<Text color="gray.600" fontSize="sm">
Source: {source}
</Text>
)}
</Dialog.Body> </Dialog.Body>
<Dialog.Footer> <Dialog.Footer>
<Button ref={cancelRef} onClick={onClose}> <Button
ref={cancelRef as any}
onClick={onClose}
disabled={!!isLoading}
variant="outline"
colorPalette="gray"
>
Cancel Cancel
</Button> </Button>
<Button colorPalette="red" onClick={onConfirm} ml={3}> <Button
colorPalette="red"
onClick={onConfirm}
ml={3}
disabled={!!isLoading}
>
Delete Delete
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog.Positioner> </Dialog.Positioner>
</Dialog.Root> </Dialog.Root>
); */ );
} }

View File

@@ -13,6 +13,7 @@ import SearchBar from "./_components/SearchBar";
import TranscriptTable from "./_components/TranscriptTable"; import TranscriptTable from "./_components/TranscriptTable";
import TranscriptCards from "./_components/TranscriptCards"; import TranscriptCards from "./_components/TranscriptCards";
import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
import { formatLocalDate } from "../../lib/time";
export default function TranscriptBrowser() { export default function TranscriptBrowser() {
const [selectedSourceKind, setSelectedSourceKind] = const [selectedSourceKind, setSelectedSourceKind] =
@@ -25,7 +26,7 @@ export default function TranscriptBrowser() {
page, page,
selectedSourceKind, selectedSourceKind,
selectedRoomId, selectedRoomId,
searchTerm, searchTerm
); );
const userName = useSessionUser().name; const userName = useSessionUser().name;
const [deletionLoading, setDeletionLoading] = useState(false); const [deletionLoading, setDeletionLoading] = useState(false);
@@ -50,7 +51,7 @@ export default function TranscriptBrowser() {
const handleFilterTranscripts = ( const handleFilterTranscripts = (
sourceKind: SourceKind | null, sourceKind: SourceKind | null,
roomId: string, roomId: string
) => { ) => {
setSelectedSourceKind(sourceKind); setSelectedSourceKind(sourceKind);
setSelectedRoomId(roomId); setSelectedRoomId(roomId);
@@ -96,9 +97,8 @@ export default function TranscriptBrowser() {
const onCloseDeletion = () => setTranscriptToDeleteId(undefined); const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
const handleDeleteTranscript = (transcriptId) => (e) => { const confirmDeleteTranscript = (transcriptId: string) => {
e.stopPropagation(); if (!api || deletionLoading) return;
if (api && !deletionLoading) {
setDeletionLoading(true); setDeletionLoading(true);
api api
.v1TranscriptDelete({ transcriptId }) .v1TranscriptDelete({ transcriptId })
@@ -106,16 +106,19 @@ export default function TranscriptBrowser() {
refetch(); refetch();
setDeletionLoading(false); setDeletionLoading(false);
onCloseDeletion(); onCloseDeletion();
setDeletedItemIds((deletedItemIds) => [ setDeletedItemIds((prev) =>
deletedItemIds, prev ? [...prev, transcriptId] : [transcriptId]
...transcriptId, );
]);
}) })
.catch((err) => { .catch((err) => {
setDeletionLoading(false); setDeletionLoading(false);
setError(err, "There was an error deleting the transcript"); setError(err, "There was an error deleting the transcript");
}); });
} };
const handleDeleteTranscript = (transcriptId: string) => (e: any) => {
e?.stopPropagation?.();
setTranscriptToDeleteId(transcriptId);
}; };
const handleProcessTranscript = (transcriptId) => (e) => { const handleProcessTranscript = (transcriptId) => (e) => {
@@ -127,7 +130,7 @@ export default function TranscriptBrowser() {
if (status === "already running") { if (status === "already running") {
setError( setError(
new Error("Processing is already running, please wait"), new Error("Processing is already running, please wait"),
"Processing is already running, please wait", "Processing is already running, please wait"
); );
} }
}) })
@@ -137,6 +140,19 @@ export default function TranscriptBrowser() {
} }
}; };
const transcriptToDelete = response?.items?.find(
(i) => i.id === transcriptToDeleteId
);
const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
const dialogDate = transcriptToDelete?.created_at
? formatLocalDate(transcriptToDelete.created_at)
: undefined;
const dialogSource = transcriptToDelete
? transcriptToDelete.source_kind === "room"
? transcriptToDelete.room_name || undefined
: transcriptToDelete.source_kind
: undefined;
return ( return (
<Flex <Flex
flexDir="column" flexDir="column"
@@ -197,8 +213,14 @@ export default function TranscriptBrowser() {
<DeleteTranscriptDialog <DeleteTranscriptDialog
isOpen={!!transcriptToDeleteId} isOpen={!!transcriptToDeleteId}
onClose={onCloseDeletion} onClose={onCloseDeletion}
onConfirm={() => handleDeleteTranscript(transcriptToDeleteId)(null)} onConfirm={() =>
transcriptToDeleteId && confirmDeleteTranscript(transcriptToDeleteId)
}
cancelRef={cancelRef} cancelRef={cancelRef}
isLoading={deletionLoading}
title={dialogTitle}
date={dialogDate}
source={dialogSource}
/> />
</Flex> </Flex>
); );