Skip to content

Implement support for downloading videos from Google Drive - #4715

Open
marcoacierno wants to merge 15 commits into
mainfrom
google-drive-video-imports
Open

Implement support for downloading videos from Google Drive#4715
marcoacierno wants to merge 15 commits into
mainfrom
google-drive-video-imports

Conversation

@marcoacierno

Copy link
Copy Markdown
Member
  • refactor(video_uploads): rename model to VideosImportRequest
  • refactor(video_uploads): rename Celery task to process_videos_import_request
  • refactor(video_uploads): extract BaseTransferProcessing and provider factory
  • feat(video_uploads): validate the source URL when the admin form is saved
  • feat(google_api): track a daily Drive quota per OAuth credential
  • feat(google_api): add Drive read-only scope and Drive REST helpers
  • feat(video_uploads): parse Google Drive file and folder URLs
  • feat(video_uploads): import single files from Google Drive
  • feat(video_uploads): import Google Drive folders recursively
  • feat(video_uploads): explain Drive import failures in failed_reason
  • test(video_uploads): cover the Drive import end to end

What

ToDo

Rename WetransferToS3TransferRequest to VideosImportRequest and its
wetransfer_url field to source_url, ahead of adding Google Drive as a
second import provider. Provider-neutral names let one model, one status
machine and one admin page serve both WeTransfer and Drive imports.

Migration 0002 is a pure RenameModel + RenameField (verified via
sqlmigrate to emit ALTER TABLE ... RENAME, no data mutation).

is_s3_storage switches from == to is: pre-existing E721 that pre-commit
only surfaces now that the file is touched. Comparing type objects with
is has identical semantics, so the exact-type check is unchanged.

Celery task, admin helper and log strings keep their old names for now;
they are renamed in the following commit to keep this diff mechanical.
…request

Complete the provider-neutral rename: the Celery task, the admin queueing
helper, the processing class attribute, log messages and temp file
prefixes all drop the wetransfer name. Only get_download_link keeps
WeTransfer-specific local names, since that method is WeTransfer's link
resolution and stays provider-specific.

OnlyOneAtTimeTask derives its Redis lock key from the task module and
name, so the rename changes the lock key. Requests left QUEUED across the
deploy are re-triggered from the admin retry action.
…factory

Split transfer.py into the provider-agnostic blob pipeline and the
WeTransfer provider on top of it, so Google Drive can reuse the ranged
multi-part download, zip extraction and S3 upload machinery.

BaseTransferProcessing.import_blob imports whatever the subclass points
download_link/filename/extension/transfer_total_size at, which lets a
provider call it once per file when importing a folder. Providers add
auth via the download_headers hook, merged with the Range header on every
part attempt so a token refreshed mid-download is picked up. WeTransfer
returns no extra headers, so its requests are unchanged - asserted by a
new test over the full request history.

get_processing_class maps hostname to provider and raises
UnsupportedVideoImportUrlError otherwise. This replaces the implicit
"any hostname works" behaviour of the old code, which built the
WeTransfer API URL from whatever host the user pasted.
…aved

Reject unsupported import URLs when an organizer saves the form, instead
of accepting the request and failing later inside the Celery task.

Validation delegates to get_processing_class, so Drive URLs become valid
automatically once that provider is registered - the admin needs no
further change.
The quota machinery resolves the limit column by interpolating the
service name, so adding quota_limit_for_drive is all that is needed for
with_quota_left("drive") and get_available_credentials_token("drive", n)
to work.

Drive's real limits are per-user rates rather than a daily bucket, so
this is a safety valve against runaway imports, not an enforcement of
Google's own limits.
Add the drive.readonly scope and three helpers the video import pipeline
needs: refreshed credentials for callers that issue their own ranged
media downloads, single file metadata, and a paginated folder listing.

The helpers call the Drive REST API with requests rather than
googleapiclient so that tests can intercept them, and so the same Bearer
token can be reused for the ranged download in video_uploads.

Credentials rebuilt from a stored token are always expired, because
from_authorized_user_info records no expiry: googleapiclient hid this by
refreshing lazily inside build(), but a raw requests call would send the
stale token, so every helper refreshes explicitly first.

Existing tokens only carry the YouTube scope. They must be re-authorized
from the admin before the first Drive import, otherwise Drive answers 403.
Recognise the URL shapes organizers actually paste - /file/d/<id>/view,
/file/d/<id>/edit, /open?id=<id>, /drive/folders/<id> and the
/drive/u/<n>/folders/<id> variant produced when several Google accounts
are signed in - and reject anything else with a message naming the two
supported shapes.
Add the Drive provider for file links: read name, size and mimeType from
the Drive API, then pull the bytes through the existing blob pipeline via
the alt=media endpoint, which honours Range requests so large recordings
still download as parallel parts. A zip on Drive is unzipped exactly like
a WeTransfer one.

download_headers is re-evaluated on every part attempt under a lock, so
a token that expires part-way through a long download is refreshed
instead of failing the import.

Google-native files carry no downloadable bytes, so a file link pointing
at a Doc, Sheet or Slides fails with a message telling the organizer to
export and share a regular file.
Walk a shared folder depth-first, following nextPageToken, and import
every file through the same blob pipeline, storing each one under its
path relative to the shared folder so the videographer's structure
survives the import.

Docs, Sheets, Slides and shortcuts have no bytes behind them. They are
logged and skipped rather than failing the folder, which matches how zip
imports already skip junk entries.
Translate the three failures an organizer can actually act on into
messages that say what to do: no Drive-capable credential, Google
refusing access, and a link that points at nothing reachable.

NoGoogleCloudQuotaLeftError carries no message at all, so without this it
reached the admin as an empty failed_reason.
Drive the whole path through the Celery task with only HTTP mocked, so
the task, the provider factory, the Drive provider and the storage write
are exercised together rather than behind a patched run().

Also check the admin accepts both Drive link shapes and queues them onto
heavy_processing, the same queue WeTransfer imports use.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pycon Error Error Aug 7, 2026 8:14pm

Comment thread backend/video_uploads/tests/test_admin.py Fixed
with pytest.raises(UnsupportedVideoImportUrlError) as exc:
get_processing_class("https://example.com/some-video.mp4")

assert "example.com" in str(exc.value)
Say "WeTransfer or Google Drive" when rejecting an unsupported URL: the
message still named only WeTransfer, from before the Drive provider
existed.

Drop the unused imported_files attribute on the processing object. Both
providers return their list directly and the model field of the same
name is what actually gets written.

Move the Drive mime helpers above their only caller and derive the folder
type from the shared google-apps prefix, which makes it visible that
folders match is_google_native too and so must be ruled out first.

Rename import_drive_file to import_file, since inside GoogleDriveProcessing
every method imports a Drive file. Its caller becomes import_file_by_id,
naming how the file is addressed rather than repeating the provider.

Build the folder listing params once instead of rebuilding them per page.
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.93814% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.49%. Comparing base (4aeed0b) to head (0e13ce8).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4715      +/-   ##
==========================================
+ Coverage   92.43%   92.49%   +0.06%     
==========================================
  Files         355      355              
  Lines       10719    10862     +143     
  Branches      818      837      +19     
==========================================
+ Hits         9908    10047     +139     
- Misses        698      699       +1     
- Partials      113      116       +3     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

This PR renames WetransferToS3TransferRequest to VideosImportRequest, splits the transfer pipeline into a provider-agnostic base class plus WeTransfer/Google Drive providers, and adds Drive file/folder import with per-credential quota tracking and admin-time URL validation.

Correctness: zip entries lose their folder path when a Drive subfolder contains a zip

In backend/video_uploads/transfer.py, import_file sets self.filename to the path relative to the imported folder (e.g. "day2/videos.zip"), and non-zip files are saved with that full path (process_downloaded_filesave_file_to_s3(self.filename, ...), line 161). But when the downloaded blob is a zip, extraction goes through process_zip_fileprocess_zip_file_obj (lines 165-193), which saves each entry under file_info.filename only — the folder-relative prefix (self.filename) is discarded entirely.

Concretely: importing a Drive folder that contains day2/videos.zip (with entries talk1.mp4, talk2.mp4) will save those files to conference-videos/{code}/talk1.mp4 instead of conference-videos/{code}/day2/talk1.mp4. If two different subfolders each contain a zip with same-named entries, one will silently overwrite the other in storage, and imported_files will report inconsistent paths (prefixed for plain files, unprefixed for zip contents). This isn't covered by any test — test_drive_unzips_a_zip_file only exercises a zip as the top-level import, where there's no prefix to lose.

Worth confirming: per-call Drive quota check is bypassed once credentials are pinned

count_quota in backend/google_api/sdk.py only calls get_available_credentials (which enforces min_quota) when no credentials are passed in. GoogleDriveProcessing resolves credentials once via get_drive_credentials() and passes them to every subsequent drive_file_metadata/drive_list_files_in_folder call, so after the initial check, quota usage is recorded but never re-verified for the rest of the import. A large folder import can drive one account's usage well past quota_limit_for_drive before the safety valve has any effect (it only blocks the next import). This may be an accepted tradeoff for the "same account for the whole import" fix, but worth confirming that's intentional given the quota field exists specifically to cap per-account usage.

The old assertion only checked that the refused hostname was echoed back,
through str() of an ErrorList, which renders HTML. That let two real
regressions through: the message naming only WeTransfer months after
Drive shipped, and the message stripped to a bare hostname. Both were
verified to keep the old test green.

Assert on the raw error instead: exactly one error, on source_url alone.
Then guard the two things the message is actually for.

The staleness bug cannot be caught by asserting on copy, because the copy
never changed - the provider registry did. So the second test compares
PROCESSING_CLASSES_BY_HOSTNAME against the test's own table of names an
organizer must see, and fails if a provider is registered without anyone
revisiting the wording. It keys on the provider class rather than the
hostname, so adding a host alias costs nothing but adding a provider
stops the suite.

The lookalike cases pin the allowlist as the SSRF control it replaced:
exact-match hostnames, so wetransfer.com.evil.example is refused, and a
userinfo host like drive.google.com@169.254.169.254 is reported as the
metadata address it really resolves to.

Verified by mutation: stale copy, stripped copy, dropped hostname, a
lookalike added to the allowlist, a suffix-matching allowlist, a third
provider left out of the message, and validation removed altogether are
each caught. Production code is unchanged.
@marcoacierno marcoacierno changed the title google drive video imports Implement support for downloading videos from Google Drive Aug 7, 2026
GoogleDriveProcessing resolved credentials once and used them for the
byte downloads, but drive_file_metadata and drive_list_files_in_folder
took none, so count_quota resolved a fresh account for each of them. With
more than one Drive-capable credential the metadata lookup, the folder
listing and the download could run as different Google accounts, and a
folder shared with only one of them failed with a 403 on files the
organizer could plainly see.

count_quota now accepts credentials the caller already holds and reuses
them instead of picking an account, still charging the quota. Passing
them was previously impossible: it collided with the injected keyword and
raised TypeError. Callers that pass nothing are unaffected, so the
YouTube helpers keep resolving per call as before.

The regression test gives the first account exactly enough quota for one
call, so a second lookup would fall through to the other account, and
asserts every Drive request in the import carries the same bearer token.
It fails if either call stops being handed the pinned credentials.
@marcoacierno
marcoacierno marked this pull request as ready for review August 7, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants