Fix broken download for long hosted-meeting recordings#319
Fix broken download for long hosted-meeting recordings#319mikewillems wants to merge 8 commits intostagingfrom
Conversation
|
Visit the preview URL for this PR (updated for commit 31be640): https://gen-hls-bkc-7627--pr319-mw-fix-hosted-record-g1depyz4.web.app (expires Fri, 20 Mar 2026 17:52:45 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: eed668cca81618d491d024574a8f8a6003deaa8d |
5511a52 to
e7df3f3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.
Files not reviewed (1)
- firebase/functions/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| final idToken = | ||
| await userService.firebaseAuth.currentUser?.getIdToken(); | ||
|
|
||
| final response = await http.post( | ||
| Uri.parse( | ||
| '${Environment.functionsUrlPrefix}/downloadRecording', | ||
| ), | ||
| headers: {'Authorization': 'Bearer $idToken'}, | ||
| body: {'eventPath': event.fullPath}, |
There was a problem hiding this comment.
getIdToken() can return null when there is no current user; this code would send Authorization: Bearer null, which then fails server-side. Consider guarding on idToken == null and surfacing a clear auth error before making the request.
| if (recordings.isEmpty) { | ||
| throw Exception('No recordings found for this event'); | ||
| } | ||
|
|
There was a problem hiding this comment.
setState(() => _recordingLinks[event.id] = recordings); runs after multiple awaits. If the user navigates away while the request is in flight, this can throw "setState() called after dispose()". Consider checking if (!mounted) return; before calling setState.
| if (!mounted) return; |
katherineqian
left a comment
There was a problem hiding this comment.
Going to just submit the comments I have at this stage - I completed a review of the code and it looks good to me. Still need to re-test the behavior and the UI; please re-request when ready!
| archive.on('error', (err) => { | ||
| console.error('Error creating zip file:', err) | ||
| }) | ||
| const [files] = await bucket.getFiles({ prefix: `${event.id}/` }) |
|
|
||
| var _numToShow = 10; | ||
|
|
||
| /// Signed GCS URLs returned for each event, keyed by event ID. |
There was a problem hiding this comment.
Very helpful comment!
| } | ||
|
|
||
| const bucket = storage.bucket(bucketName) | ||
| const [files] = await bucket.getFiles({ prefix: `${event.id}/` }) |
There was a problem hiding this comment.
This is not related to the improvements you introduced, but it would have been nice to organize this bucket within folders under community/template for DX, since that's the way Firestore is currently organized. I know your plans for sensemaking also propose changes to structure here, though the proposal there was for the new path to add further levels of granularity beyond eventId.
There are definitely some concerns with backwards compatibility, though since there aren't a lot of recordings on prod currently, I think a one-time export of historical recordings for beta clients could work here if we want to reorganize. I'll follow up on Slack about this, since we should also chat about the new GCS recording path wrt breakout room recordings!
This comment is non-blocking for this PR - we can change the path in a subsequent PR.
What is in this PR?
Downloading recordings for long events either spun indefinitely or returned a user-facing error.
There were two potential causes, this PR fixes both.
Cause 1: The function was trying to do download by streaming synchronously (bless its heart).
Cause 2: Agora splits video downloads into a max size of the lesser of 2GB or 2 hours. This could leave a synchronous function hanging until the other download chunk is requested.
More technically:
downloadRecordingstreamed the entire ZIP file through the Cloud Function HTTP response, and the Flutter client read the full response body into memory before creating a blob URL. For long recordings the function hit its timeout mid-stream, the connection drops, andresponse.bodyBytesis null. Normally one would make the function async at that point, but instead the timeout was overridden with 5 minutes instead of 1 minute, which aside from being the wrong approach, still isn't long enough for long recordings, especially depending on network connection. I also don't know what kind of egress bandwidth we have on GCS.The CORS error visible in devtools was just the signal that we didn't have a file to download.
Changes in the codebase
Replace ZIP streaming with signed GCS URLs.
The function now lists all MP4 files under
{event.id}/in GCS, generates a short-lived signed URL for each, and returns them as JSON. File bytes never pass through the function -- the whole call returns near-instantly regardless of recording length. The Flutter client renders each URL as a clickable link; the browser handles the download when the user taps it.Commit 1 (caea101): switching download scheme to signed direct-from-GCS URL
firebase/functions/js/download-recordings.jsarchiverand the 300-second timeout override.{event.id}/, generate signed GCS URLs, return as{ recordings: [{ name, url }] }.HttpsErrorinside anonRequesthandler (which does not propagate correctly outsideonCall).Commit 2 (bb80712): hygiene
firebase/functions/package.jsonRemoved unused
archiverdependencyCommit 3 (6785311): client download initiation
client/lib/features/admin/presentation/views/events_tab.darthtml.window.open().dart:convertimport.Commit 4 (e7a1fe9): hardening the download endpoint
firebase/functions/js/download-recordings.jseventPathRegexvalidation before passingeventPathtofirestore.doc(). Without it any caller with a valid auth token could supply an arbitrary Firestore path and read documents outside the events collection. The regex enforces the expected shape:community/{id}/templates/{id}/events/{id}(derived from theFirestoreDatabase.templateReferencepath builder andFirestoreEventService.eventsCollection).mp4Filesby name before generating URLs so recordings are returned in a deterministic order regardless of GCS list ordering.Commit 5 (e7df3f3): UI visual consistency, use tapped links instead of popups in case blocked
client/lib/features/admin/presentation/views/events_tab.darthtml.window.open()loop (called after anasyncgap, so popup blockers fire) with a stateful approach: the button press stores the returned URLs in_recordingLinks(aMap<String, List<Map<String, String>>>keyed by event ID), and_buildRecordingSectionrenders numberedGestureDetectorlinks below the button once populated. Links persist on screen until the next navigation, and any subsequent button press refreshes them.jsonDecode(try/catch) and therecordingsfield (is! Listcheck) to surface a clean user-facing error instead of a runtime cast exception if the server returns malformed JSON or an unexpected schema.TextforHeightConstrainedTexton the rendered links to match every other text element in the table, and removed the hardcodedfontSize: 12(nothing inAppTextStyleuses 12; the smallest defined size isbodySmallat 14). Both changes bring the link style in line with the date link in the same row and other admin-view link patterns (Colors.blue+TextDecoration.underline).Changes outside the codebase
None.
Testing this PR
For existing recordings (this should test it completely)
Go to any long recording over ~1h and attempt to download it.
Verify that the app doesn't freeze and that a new tab opens with the proper download.
Do the same for a recording over ~2h (maybe 3?) and verify that you get two downloads.
Start a hosted meeting with recording, keep it short.
Download recording - verify it still works for short recordings, but opens in a new tab.
Start a meeting with recording, let it run for 1h.
End the meeting and download the recording. Verify that the app continues running and that a new tab opens.
Start a meeting with recording, let it run for at least 3h. Verify that the app continues running, and TWO tabs open with two downloads (at this point Agora should split the according into two files per its docs).
Additional information
Note: This fixes downloads for the existing main-room hosted recording flow only. Per-session and breakout room download support is tracked in phases 4 and 5 of Breakout Room Recordings #9.