11"""Egress manager for recording dual-channel audio to S3."""
22
3+ import asyncio
34import os
5+ from dataclasses import dataclass
46
57from livekit import api
68from livekit .protocol import egress as egress_proto
79from loguru import logger
810
911
12+ @dataclass
13+ class EgressFileInfo :
14+ """Information about an uploaded egress file."""
15+
16+ filename : str
17+ location : str
18+ duration : int
19+ size : int
20+
21+
1022class EgressConfig :
1123 """Configuration for egress recordings."""
1224
@@ -63,6 +75,7 @@ def __init__(self, config: EgressConfig):
6375 self .config = config
6476 self ._api : api .LiveKitAPI | None = None
6577 self ._egress_id : str | None = None
78+ self ._expected_filepath : str | None = None
6679
6780 @property
6881 def livekit_api (self ) -> api .LiveKitAPI :
@@ -120,6 +133,11 @@ async def start_dual_channel_recording(
120133 else :
121134 filepath = f"{ filepath_prefix } /{{room_name}}-{{time}}.ogg"
122135
136+ # Store expected filepath for logging
137+ self ._expected_filepath = filepath
138+ expected_s3_path = f"s3://{ self .config .s3_bucket } /{ filepath } "
139+ logger .info (f"Expected audio file path: { expected_s3_path } " )
140+
123141 file_output = egress_proto .EncodedFileOutput (
124142 filepath = filepath ,
125143 s3 = s3_upload ,
@@ -138,35 +156,145 @@ async def start_dual_channel_recording(
138156
139157 self ._egress_id = info .egress_id
140158 logger .info (
141- f"Started dual-channel egress recording for room { room_name } , "
142- f"egress_id={ self ._egress_id } "
159+ f"Started egress recording for room { room_name } , "
160+ f"egress_id={ self ._egress_id } , "
161+ f"expected_file={ expected_s3_path } "
143162 )
144163 return self ._egress_id
145164
146165 except Exception as e :
147166 logger .error (f"Failed to start egress recording: { e } " )
148167 return None
149168
150- async def stop_recording (self ) -> bool :
151- """Stop the active egress recording.
169+ async def stop_recording (self ) -> EgressFileInfo | None :
170+ """Stop the active egress recording and wait for upload to complete .
152171
153172 Returns:
154- True if stopped successfully or no active recording, False on error
173+ EgressFileInfo with file details if successful, None on error
155174 """
156175 if not self ._egress_id :
157176 logger .debug ("No active egress to stop" )
158- return True
177+ return None
159178
179+ egress_id = self ._egress_id
160180 try :
181+ logger .info (f"Stopping egress recording, egress_id={ egress_id } ..." )
161182 await self .livekit_api .egress .stop_egress (
162- egress_proto .StopEgressRequest (egress_id = self ._egress_id )
183+ egress_proto .StopEgressRequest (egress_id = egress_id )
184+ )
185+ logger .info (
186+ f"Stop request sent for egress_id={ egress_id } , waiting for upload to complete..."
163187 )
164- logger .info (f"Stopped egress recording, egress_id={ self ._egress_id } " )
188+
189+ # Wait for the egress to complete and get file info
190+ file_info = await self ._wait_for_completion (egress_id )
165191 self ._egress_id = None
166- return True
192+ self ._expected_filepath = None
193+ return file_info
194+
167195 except Exception as e :
168196 logger .error (f"Failed to stop egress recording: { e } " )
169- return False
197+ return None
198+
199+ async def _wait_for_completion (
200+ self , egress_id : str , timeout : float = 60.0 , poll_interval : float = 1.0
201+ ) -> EgressFileInfo | None :
202+ """Wait for egress to complete and return file info.
203+
204+ Args:
205+ egress_id: The egress ID to wait for
206+ timeout: Maximum time to wait in seconds
207+ poll_interval: Time between status checks in seconds
208+
209+ Returns:
210+ EgressFileInfo with file details if successful, None on error/timeout
211+ """
212+ start_time = asyncio .get_event_loop ().time ()
213+ last_status = None
214+
215+ while True :
216+ elapsed = asyncio .get_event_loop ().time () - start_time
217+ if elapsed > timeout :
218+ logger .error (
219+ f"Timeout waiting for egress completion after { timeout } s, "
220+ f"egress_id={ egress_id } , last_status={ last_status } "
221+ )
222+ return None
223+
224+ try :
225+ # List egress by ID to get current status
226+ response = await self .livekit_api .egress .list_egress (
227+ egress_proto .ListEgressRequest (egress_id = egress_id )
228+ )
229+
230+ if not response .items :
231+ logger .warning (f"Egress { egress_id } not found in list response" )
232+ await asyncio .sleep (poll_interval )
233+ continue
234+
235+ egress_info = response .items [0 ]
236+ status = egress_info .status
237+ last_status = egress_proto .EgressStatus .Name (status )
238+
239+ logger .debug (
240+ f"Egress status: { last_status } , egress_id={ egress_id } , "
241+ f"elapsed={ elapsed :.1f} s"
242+ )
243+
244+ # Check terminal states
245+ if status == egress_proto .EgressStatus .EGRESS_COMPLETE :
246+ # Extract file info from results
247+ if egress_info .file_results :
248+ file_result = egress_info .file_results [0 ]
249+ file_info = EgressFileInfo (
250+ filename = file_result .filename ,
251+ location = file_result .location ,
252+ duration = file_result .duration ,
253+ size = file_result .size ,
254+ )
255+ logger .info (
256+ f"Egress completed successfully! "
257+ f"File uploaded to: { file_info .location } , "
258+ f"filename={ file_info .filename } , "
259+ f"duration={ file_info .duration } ns, "
260+ f"size={ file_info .size } bytes"
261+ )
262+ return file_info
263+ else :
264+ logger .warning (
265+ f"Egress completed but no file_results found, "
266+ f"egress_id={ egress_id } "
267+ )
268+ return None
269+
270+ elif status == egress_proto .EgressStatus .EGRESS_FAILED :
271+ error_msg = egress_info .error or "Unknown error"
272+ logger .error (f"Egress failed: { error_msg } , egress_id={ egress_id } " )
273+ return None
274+
275+ elif status == egress_proto .EgressStatus .EGRESS_ABORTED :
276+ logger .warning (f"Egress was aborted, egress_id={ egress_id } " )
277+ return None
278+
279+ elif status == egress_proto .EgressStatus .EGRESS_LIMIT_REACHED :
280+ logger .warning (f"Egress limit reached, egress_id={ egress_id } " )
281+ # Still try to get file info as partial recording may exist
282+ if egress_info .file_results :
283+ file_result = egress_info .file_results [0 ]
284+ return EgressFileInfo (
285+ filename = file_result .filename ,
286+ location = file_result .location ,
287+ duration = file_result .duration ,
288+ size = file_result .size ,
289+ )
290+ return None
291+
292+ # Still in progress, wait and poll again
293+ await asyncio .sleep (poll_interval )
294+
295+ except Exception as e :
296+ logger .error (f"Error polling egress status: { e } " )
297+ await asyncio .sleep (poll_interval )
170298
171299 async def close (self ) -> None :
172300 """Clean up resources."""
0 commit comments