mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a1d662dc4 | |||
| 033bd4bc48 | |||
| 0eb670ca19 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [0.2.0](https://github.com/Monadical-SAS/reflector/compare/0.1.1...v0.2.0) (2025-07-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve transcript listing with room_id ([#496](https://github.com/Monadical-SAS/reflector/issues/496)) ([d2b5de5](https://github.com/Monadical-SAS/reflector/commit/d2b5de543fc0617fc220caa6a8a290e4040cb10b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't attempt to load waveform/mp3 if audio was deleted ([#495](https://github.com/Monadical-SAS/reflector/issues/495)) ([f4578a7](https://github.com/Monadical-SAS/reflector/commit/f4578a743fd0f20312fbd242fa9cccdfaeb20a9e))
|
||||
|
||||
## [0.1.1](https://github.com/Monadical-SAS/reflector/compare/0.1.0...v0.1.1) (2025-07-17)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Add room_id to transcript
|
||||
|
||||
Revision ID: d7fbb74b673b
|
||||
Revises: a9c9c229ee36
|
||||
Create Date: 2025-07-17 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d7fbb74b673b"
|
||||
down_revision: Union[str, None] = "a9c9c229ee36"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add room_id column to transcript table
|
||||
op.add_column("transcript", sa.Column("room_id", sa.String(), nullable=True))
|
||||
|
||||
# Add index for room_id for better query performance
|
||||
op.create_index("idx_transcript_room_id", "transcript", ["room_id"])
|
||||
|
||||
# Populate room_id for existing ROOM-type transcripts
|
||||
# This joins through recording -> meeting -> room to get the room_id
|
||||
op.execute("""
|
||||
UPDATE transcript AS t
|
||||
SET room_id = r.id
|
||||
FROM recording rec
|
||||
JOIN meeting m ON rec.meeting_id = m.id
|
||||
JOIN room r ON m.room_id = r.id
|
||||
WHERE t.recording_id = rec.id
|
||||
AND t.source_kind = 'room'
|
||||
AND t.room_id IS NULL
|
||||
""")
|
||||
|
||||
# Fix missing meeting_id for ROOM-type transcripts
|
||||
# The meeting_id field exists but was never populated
|
||||
op.execute("""
|
||||
UPDATE transcript AS t
|
||||
SET meeting_id = rec.meeting_id
|
||||
FROM recording rec
|
||||
WHERE t.recording_id = rec.id
|
||||
AND t.source_kind = 'room'
|
||||
AND t.meeting_id IS NULL
|
||||
AND rec.meeting_id IS NOT NULL
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop the index first
|
||||
op.drop_index("idx_transcript_room_id", "transcript")
|
||||
|
||||
# Drop the room_id column
|
||||
op.drop_column("transcript", "room_id")
|
||||
@@ -74,10 +74,12 @@ transcripts = sqlalchemy.Table(
|
||||
# the main "audio deleted" is the presence of the audio itself / consents not-given
|
||||
# same field could've been in recording/meeting, and it's maybe even ok to dupe it at need
|
||||
sqlalchemy.Column("audio_deleted", sqlalchemy.Boolean),
|
||||
sqlalchemy.Column("room_id", sqlalchemy.String),
|
||||
sqlalchemy.Index("idx_transcript_recording_id", "recording_id"),
|
||||
sqlalchemy.Index("idx_transcript_user_id", "user_id"),
|
||||
sqlalchemy.Index("idx_transcript_created_at", "created_at"),
|
||||
sqlalchemy.Index("idx_transcript_user_id_recording_id", "user_id", "recording_id"),
|
||||
sqlalchemy.Index("idx_transcript_room_id", "room_id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -167,6 +169,7 @@ class Transcript(BaseModel):
|
||||
zulip_message_id: int | None = None
|
||||
source_kind: SourceKind
|
||||
audio_deleted: bool | None = None
|
||||
room_id: str | None = None
|
||||
|
||||
@field_serializer("created_at", when_used="json")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
@@ -331,17 +334,10 @@ class TranscriptController:
|
||||
- `room_id`: filter transcripts by room ID
|
||||
- `search_term`: filter transcripts by search term
|
||||
"""
|
||||
from reflector.db.meetings import meetings
|
||||
from reflector.db.recordings import recordings
|
||||
from reflector.db.rooms import rooms
|
||||
|
||||
query = (
|
||||
transcripts.select()
|
||||
.join(
|
||||
recordings, transcripts.c.recording_id == recordings.c.id, isouter=True
|
||||
)
|
||||
.join(meetings, recordings.c.meeting_id == meetings.c.id, isouter=True)
|
||||
.join(rooms, meetings.c.room_id == rooms.c.id, isouter=True)
|
||||
query = transcripts.select().join(
|
||||
rooms, transcripts.c.room_id == rooms.c.id, isouter=True
|
||||
)
|
||||
|
||||
if user_id:
|
||||
@@ -355,7 +351,7 @@ class TranscriptController:
|
||||
query = query.where(transcripts.c.source_kind == source_kind)
|
||||
|
||||
if room_id:
|
||||
query = query.where(rooms.c.id == room_id)
|
||||
query = query.where(transcripts.c.room_id == room_id)
|
||||
|
||||
if search_term:
|
||||
query = query.where(transcripts.c.title.ilike(f"%{search_term}%"))
|
||||
@@ -368,7 +364,6 @@ class TranscriptController:
|
||||
query = query.with_only_columns(
|
||||
transcript_columns
|
||||
+ [
|
||||
rooms.c.id.label("room_id"),
|
||||
rooms.c.name.label("room_name"),
|
||||
]
|
||||
)
|
||||
@@ -419,6 +414,22 @@ class TranscriptController:
|
||||
return None
|
||||
return Transcript(**result)
|
||||
|
||||
async def get_by_room_id(self, room_id: str, **kwargs) -> list[Transcript]:
|
||||
"""
|
||||
Get transcripts by room_id (direct access without joins)
|
||||
"""
|
||||
query = transcripts.select().where(transcripts.c.room_id == room_id)
|
||||
if "user_id" in kwargs:
|
||||
query = query.where(transcripts.c.user_id == kwargs["user_id"])
|
||||
if "order_by" in kwargs:
|
||||
order_by = kwargs["order_by"]
|
||||
field = getattr(transcripts.c, order_by[1:])
|
||||
if order_by.startswith("-"):
|
||||
field = field.desc()
|
||||
query = query.order_by(field)
|
||||
results = await database.fetch_all(query)
|
||||
return [Transcript(**result) for result in results]
|
||||
|
||||
async def get_by_id_for_http(
|
||||
self,
|
||||
transcript_id: str,
|
||||
@@ -469,6 +480,8 @@ class TranscriptController:
|
||||
user_id: str | None = None,
|
||||
recording_id: str | None = None,
|
||||
share_mode: str = "private",
|
||||
meeting_id: str | None = None,
|
||||
room_id: str | None = None,
|
||||
):
|
||||
"""
|
||||
Add a new transcript
|
||||
@@ -481,6 +494,8 @@ class TranscriptController:
|
||||
user_id=user_id,
|
||||
recording_id=recording_id,
|
||||
share_mode=share_mode,
|
||||
meeting_id=meeting_id,
|
||||
room_id=room_id,
|
||||
)
|
||||
query = transcripts.insert().values(**transcript.model_dump())
|
||||
await database.execute(query)
|
||||
|
||||
@@ -101,6 +101,8 @@ async def process_recording(bucket_name: str, object_key: str):
|
||||
user_id=room.user_id,
|
||||
recording_id=recording.id,
|
||||
share_mode="public",
|
||||
meeting_id=meeting.id,
|
||||
room_id=room.id,
|
||||
)
|
||||
|
||||
_, extension = os.path.splitext(object_key)
|
||||
|
||||
@@ -12,7 +12,7 @@ import FinalSummary from "./finalSummary";
|
||||
import TranscriptTitle from "../transcriptTitle";
|
||||
import Player from "../player";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
|
||||
import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
|
||||
|
||||
type TranscriptDetails = {
|
||||
params: {
|
||||
@@ -29,10 +29,13 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
const transcriptStatus = transcript.response?.status;
|
||||
const waiting = statusToRedirect.includes(transcriptStatus || "");
|
||||
|
||||
const topics = useTopics(transcriptId);
|
||||
const waveform = useWaveform(transcriptId, waiting);
|
||||
const useActiveTopic = useState<Topic | null>(null);
|
||||
const mp3 = useMp3(transcriptId, waiting);
|
||||
const topics = useTopics(transcriptId);
|
||||
const waveform = useWaveform(
|
||||
transcriptId,
|
||||
waiting || mp3.loading || mp3.audioDeleted === true,
|
||||
);
|
||||
const useActiveTopic = useState<Topic | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (waiting) {
|
||||
@@ -76,10 +79,9 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
mt={4}
|
||||
mb={4}
|
||||
>
|
||||
{waveform.waveform &&
|
||||
mp3.media &&
|
||||
!mp3.audioDeleted &&
|
||||
topics.topics ? (
|
||||
{!mp3.audioDeleted && (
|
||||
<>
|
||||
{waveform.waveform && mp3.media && topics.topics ? (
|
||||
<Player
|
||||
topics={topics?.topics}
|
||||
useActiveTopic={useActiveTopic}
|
||||
@@ -87,13 +89,15 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
media={mp3.media}
|
||||
mediaDuration={transcript.response.duration}
|
||||
/>
|
||||
) : waveform.error ? (
|
||||
<div>error loading this recording</div>
|
||||
) : mp3.audioDeleted ? (
|
||||
<div>Audio was deleted</div>
|
||||
) : !mp3.loading && (waveform.error || mp3.error) ? (
|
||||
<Box p={4} bg="red.100" borderRadius="md">
|
||||
<Text>Error loading this recording</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Skeleton h={14} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Grid
|
||||
templateColumns={{ base: "minmax(0, 1fr)", md: "repeat(2, 1fr)" }}
|
||||
templateRows={{
|
||||
@@ -108,12 +112,9 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
borderColor={"gray.bg"}
|
||||
borderRadius={8}
|
||||
>
|
||||
<GridItem
|
||||
display="flex"
|
||||
flexDir="row"
|
||||
alignItems={"center"}
|
||||
colSpan={{ base: 1, md: 2 }}
|
||||
>
|
||||
<GridItem colSpan={{ base: 1, md: 2 }}>
|
||||
<Flex direction="column" gap={0}>
|
||||
<Flex alignItems="center" gap={2}>
|
||||
<TranscriptTitle
|
||||
title={transcript.response.title || "Unnamed Transcript"}
|
||||
transcriptId={transcriptId}
|
||||
@@ -121,6 +122,14 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
transcript.reload();
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
{mp3.audioDeleted && (
|
||||
<Text fontSize="xs" color="gray.600" fontStyle="italic">
|
||||
No audio is available because one or more participants didn't
|
||||
consent to keep the audio
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</GridItem>
|
||||
<TopicList
|
||||
topics={topics.topics || []}
|
||||
|
||||
@@ -55,8 +55,8 @@ export function TopicList({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTopic) scrollToTopic();
|
||||
}, [activeTopic]);
|
||||
if (activeTopic && autoscroll) scrollToTopic();
|
||||
}, [activeTopic, autoscroll]);
|
||||
|
||||
// scroll top is not rounded, heights are, so exact match won't work.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
|
||||
@@ -105,8 +105,10 @@ export function TopicList({
|
||||
const requireLogin = featureEnabled("requireLogin");
|
||||
|
||||
useEffect(() => {
|
||||
if (autoscroll) {
|
||||
setActiveTopic(topics[topics.length - 1]);
|
||||
}, [topics]);
|
||||
}
|
||||
}, [topics, autoscroll]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
|
||||
@@ -54,62 +54,65 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
useEffect(() => {
|
||||
if (!transcriptId || !api || later) return;
|
||||
|
||||
let deleted: boolean | null = null;
|
||||
let stopped = false;
|
||||
let audioElement: HTMLAudioElement | null = null;
|
||||
let handleCanPlay: (() => void) | null = null;
|
||||
let handleError: (() => void) | null = null;
|
||||
|
||||
setTranscriptMetadataLoading(true);
|
||||
setAudioLoading(true);
|
||||
|
||||
const audioElement = document.createElement("audio");
|
||||
// First fetch transcript info to check if audio is deleted
|
||||
api
|
||||
.v1TranscriptGet({ transcriptId })
|
||||
.then((transcript) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = transcript.audio_deleted || false;
|
||||
setAudioDeleted(deleted);
|
||||
setTranscriptMetadataLoadingError(null);
|
||||
|
||||
if (deleted) {
|
||||
// Audio is deleted, don't attempt to load it
|
||||
setMedia(null);
|
||||
setAudioLoadingError(null);
|
||||
setAudioLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Audio is not deleted, proceed to load it
|
||||
audioElement = document.createElement("audio");
|
||||
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
|
||||
audioElement.crossOrigin = "anonymous";
|
||||
audioElement.preload = "auto";
|
||||
|
||||
const handleCanPlay = () => {
|
||||
if (deleted) {
|
||||
console.error(
|
||||
"Illegal state: audio supposed to be deleted, but was loaded",
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleCanPlay = () => {
|
||||
if (stopped) return;
|
||||
setAudioLoading(false);
|
||||
setAudioLoadingError(null);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
handleError = () => {
|
||||
if (stopped) return;
|
||||
setAudioLoading(false);
|
||||
if (deleted) {
|
||||
// we arrived here earlier, ignore
|
||||
return;
|
||||
}
|
||||
setAudioLoadingError("Failed to load audio");
|
||||
};
|
||||
|
||||
audioElement.addEventListener("canplay", handleCanPlay);
|
||||
audioElement.addEventListener("error", handleError);
|
||||
|
||||
if (!stopped) {
|
||||
setMedia(audioElement);
|
||||
|
||||
setAudioLoading(true);
|
||||
|
||||
let stopped = false;
|
||||
// Fetch transcript info in parallel
|
||||
api
|
||||
.v1TranscriptGet({ transcriptId })
|
||||
.then((transcript) => {
|
||||
if (stopped) return;
|
||||
deleted = transcript.audio_deleted || false;
|
||||
setAudioDeleted(deleted);
|
||||
setTranscriptMetadataLoadingError(null);
|
||||
if (deleted) {
|
||||
setMedia(null);
|
||||
setAudioLoadingError(null);
|
||||
}
|
||||
// if deleted, media will or already returned error
|
||||
})
|
||||
.catch((error) => {
|
||||
if (stopped) return;
|
||||
console.error("Failed to fetch transcript:", error);
|
||||
setAudioDeleted(null);
|
||||
setTranscriptMetadataLoadingError(error.message);
|
||||
setAudioLoading(false);
|
||||
})
|
||||
.finally(() => {
|
||||
if (stopped) return;
|
||||
@@ -118,10 +121,14 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (audioElement) {
|
||||
audioElement.src = "";
|
||||
if (handleCanPlay)
|
||||
audioElement.removeEventListener("canplay", handleCanPlay);
|
||||
audioElement.removeEventListener("error", handleError);
|
||||
if (handleError) audioElement.removeEventListener("error", handleError);
|
||||
}
|
||||
};
|
||||
}, [transcriptId, !api, later, api_url]);
|
||||
}, [transcriptId, api, later, api_url]);
|
||||
|
||||
const getNow = () => {
|
||||
setLater(false);
|
||||
|
||||
@@ -10,16 +10,22 @@ type AudioWaveFormResponse = {
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
const useWaveform = (id: string, waiting: boolean): AudioWaveFormResponse => {
|
||||
const useWaveform = (id: string, skip: boolean): AudioWaveFormResponse => {
|
||||
const [waveform, setWaveform] = useState<AudioWaveform | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setErrorState] = useState<Error | null>(null);
|
||||
const { setError } = useError();
|
||||
const api = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || !api || waiting) return;
|
||||
if (!id || !api || skip) {
|
||||
setLoading(false);
|
||||
setErrorState(null);
|
||||
setWaveform(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setErrorState(null);
|
||||
api
|
||||
.v1TranscriptGetAudioWaveform({ transcriptId: id })
|
||||
.then((result) => {
|
||||
@@ -29,14 +35,9 @@ const useWaveform = (id: string, waiting: boolean): AudioWaveFormResponse => {
|
||||
})
|
||||
.catch((err) => {
|
||||
setErrorState(err);
|
||||
const shouldShowHuman = shouldShowError(err);
|
||||
if (shouldShowHuman) {
|
||||
setError(err, "There was an error loading the waveform");
|
||||
} else {
|
||||
setError(err);
|
||||
}
|
||||
setLoading(false);
|
||||
});
|
||||
}, [id, !api, waiting]);
|
||||
}, [id, api, skip]);
|
||||
|
||||
return { waveform, loading, error };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user