mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
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:
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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_",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
34
server/tests/test_transcripts_recording_deletion.py
Normal file
34
server/tests/test_transcripts_recording_deletion.py
Normal 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
|
||||||
@@ -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>
|
||||||
); */
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user