-
Notifications
You must be signed in to change notification settings - Fork 7
Add Playwright-based YouTube video downloader with Google authentication #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
e32ad19
Initial plan for issue
Copilot 00c4eba
Complete Playwright YouTube downloader implementation
Copilot b889629
Simplify Playwright downloader to use YouTube Studio interface
Copilot 9520d71
Add login demonstration with dummy credentials
Copilot 2fce774
Add real credential testing and demonstration scripts
Copilot 270c004
Add comprehensive verification testing for YouTube Studio channel access
Copilot 5fb4ae7
Improve login method to handle 2FA removal and device verification re…
Copilot 8898453
Confirm 2FA removal and add credential testing scripts
Copilot dd52aac
Successfully test video download authentication - ready for device ve…
Copilot b63134b
Improve device verification handling in authentication flow
Copilot 62913f1
Implement MCP Playwright-based YouTube video downloader
Copilot ee8b2d2
Test 2FA status and authentication flow - confirm 2FA removed but Goo…
Copilot 8e5a13c
Revert to clean slate (commit e32ad19) as requested
Copilot 367986c
added playwright youtube download
Jonathan-Woo 673a95a
removed unused import
Jonathan-Woo ed80efe
isort fix
Jonathan-Woo f568e95
removed unused yt utils
Jonathan-Woo 35dff55
added requirements
Jonathan-Woo 6cb680a
added more prints for responsiveness
Jonathan-Woo c742667
added my_secrets_example.py to provide a template for users to fill i…
Jonathan-Woo 26e7b97
removed playlist filtering
Jonathan-Woo 98e8501
updated credentials from env variables to my_secrets
Jonathan-Woo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| import json | ||
| from pathlib import Path | ||
|
|
||
| import pyotp | ||
| from google.oauth2.credentials import Credentials | ||
| from googleapiclient.discovery import build | ||
| from playwright.sync_api import TimeoutError as PlaywrightTimeoutError | ||
| from playwright.sync_api import sync_playwright | ||
|
|
||
| from src.ac_training_lab.video_editing.my_secrets import ( | ||
| EMAIL, | ||
| PASSWORD, | ||
| TOTP_SECRET, | ||
| YOUTUBE_CLIENT_ID, | ||
| YOUTUBE_CLIENT_SECRET, | ||
| YOUTUBE_REFRESH_TOKEN, | ||
| YOUTUBE_TOKEN, | ||
| YOUTUBE_TOKEN_URI, | ||
| ) | ||
|
|
||
| # Set up TOTP for 2FA | ||
| totp = pyotp.TOTP(TOTP_SECRET) | ||
|
|
||
| OUTPUT_DIR = Path(__file__).parent / "downloaded_videos" | ||
| PROCESSED_JSON = Path(__file__).parent / "processed.json" | ||
|
|
||
|
|
||
| def list_my_playlists(youtube): | ||
| playlist_ids = [] | ||
| request = youtube.playlists().list(part="snippet", mine=True, maxResults=50) | ||
|
|
||
| while request: | ||
| response = request.execute() | ||
| for item in response.get("items", []): | ||
| playlist_id = item["id"] | ||
| title = item["snippet"]["title"] | ||
| print(f"{title}: {playlist_id}") | ||
| playlist_ids.append(playlist_id) | ||
|
|
||
| request = youtube.playlists().list_next(request, response) | ||
|
|
||
| return playlist_ids | ||
|
|
||
|
|
||
| def list_videos_in_playlist(youtube, playlist_id): | ||
| video_ids = [] | ||
| request = youtube.playlistItems().list( | ||
| part="snippet", playlistId=playlist_id, maxResults=50 | ||
| ) | ||
|
|
||
| while request: | ||
| response = request.execute() | ||
| for item in response["items"]: | ||
| video_id = item["snippet"]["resourceId"]["videoId"] | ||
| title = item["snippet"]["title"] | ||
| print(f" {title}: {video_id}") | ||
| video_ids.append(video_id) | ||
|
|
||
| request = youtube.playlistItems().list_next(request, response) | ||
|
|
||
| return video_ids | ||
|
|
||
|
|
||
| def setup_youtube_client(): | ||
| credentials = Credentials( | ||
| token=YOUTUBE_TOKEN, | ||
| refresh_token=YOUTUBE_REFRESH_TOKEN, | ||
| token_uri=YOUTUBE_TOKEN_URI, | ||
| client_id=YOUTUBE_CLIENT_ID, | ||
| client_secret=YOUTUBE_CLIENT_SECRET, | ||
| scopes=["https://www.googleapis.com/auth/youtube.force-ssl"], | ||
| ) | ||
| return build("youtube", "v3", credentials=credentials) | ||
|
|
||
|
|
||
| def load_processed(): | ||
| if PROCESSED_JSON.exists(): | ||
| with open(PROCESSED_JSON, "r") as f: | ||
| return json.load(f) | ||
| return {} | ||
|
|
||
|
|
||
| def get_pending_downloads(youtube, processed_videos, downloaded_ids): | ||
| all_videos = {} | ||
| playlist_ids = list_my_playlists(youtube) | ||
| for playlist_id in playlist_ids: | ||
| video_ids = list_videos_in_playlist(youtube, playlist_id) | ||
| all_videos[playlist_id] = [ | ||
| vid | ||
| for vid in video_ids | ||
| if vid not in processed_videos.get(playlist_id, []) | ||
| and vid not in downloaded_ids | ||
| ] | ||
| return all_videos | ||
|
|
||
|
|
||
| def login_google(page): | ||
| page.goto("https://accounts.google.com/") | ||
| page.get_by_role("textbox", name="Email or phone").fill(EMAIL) | ||
| page.get_by_role("button", name="Next").click() | ||
| page.wait_for_selector('input[name="Passwd"]') | ||
| page.get_by_role("textbox", name="Enter your password").fill(PASSWORD) | ||
| page.get_by_role("button", name="Next").click() | ||
|
|
||
| # TOTP if needed | ||
| try: | ||
| page.get_by_role( | ||
| "link", name="Get a verification code from the Google Authenticator app" | ||
| ).wait_for(timeout=5000) | ||
| except PlaywrightTimeoutError: | ||
| print("No TOTP prompt") | ||
| return | ||
|
|
||
| page.get_by_role( | ||
| "link", name="Get a verification code from the Google Authenticator app" | ||
| ).click() | ||
| page.wait_for_selector('input[name="totpPin"]', timeout=5000) | ||
| page.fill('input[name="totpPin"]', totp.now()) | ||
| page.get_by_role("button", name="Next").click() | ||
| page.wait_for_url("https://myaccount.google.com/?pli=1", timeout=10000) | ||
|
|
||
|
|
||
| def download_video(page, video_id): | ||
| try: | ||
| print(f"Navigating to video {video_id}...") | ||
| page.goto(f"https://studio.youtube.com/video/{video_id}/edit/", timeout=15000) | ||
| page.get_by_role("button", name="Options").wait_for(timeout=5000) | ||
| page.get_by_role("button", name="Options").click() | ||
| print(f"Opened video {video_id} options.") | ||
|
|
||
| page.get_by_role("menuitem", name="Download").wait_for(timeout=5000) | ||
| with page.expect_download(timeout=10000) as download_info: | ||
| page.get_by_role("menuitem", name="Download").click() | ||
| print(f"Began downloading video {video_id}...") | ||
|
|
||
| download = download_info.value | ||
| OUTPUT_DIR.mkdir(exist_ok=True) | ||
| file_path = OUTPUT_DIR / download.suggested_filename | ||
| download.save_as(file_path) | ||
| print(f"Downloaded: {file_path}") | ||
|
|
||
| except Exception as e: | ||
| print(f"Failed to download video {video_id}: {e}") | ||
|
|
||
|
|
||
| def main(): | ||
| youtube = setup_youtube_client() | ||
| processed_videos = load_processed() | ||
| downloaded_ids = set([f.stem for f in OUTPUT_DIR.glob("*.mp4")]) | ||
|
|
||
| pending = get_pending_downloads(youtube, processed_videos, downloaded_ids) | ||
| print(f"Pending downloads: {sum(len(v) for v in pending.values())}") | ||
|
|
||
| with sync_playwright() as p: | ||
| browser = p.chromium.launch(headless=False) | ||
| context = browser.new_context(accept_downloads=True) | ||
| page = context.new_page() | ||
|
|
||
| login_google(page) | ||
|
|
||
| for _, videos in pending.items(): | ||
| for video_id in videos: | ||
| download_video(page, video_id) | ||
|
|
||
| browser.close() | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| EMAIL = "" | ||
| PASSWORD = "" | ||
| TOTP_SECRET = "" | ||
| YOUTUBE_TOKEN = "" | ||
| YOUTUBE_REFRESH_TOKEN = "" | ||
| YOUTUBE_TOKEN_URI = "" | ||
| YOUTUBE_CLIENT_ID = "" | ||
| YOUTUBE_CLIENT_SECRET = "" |
Jonathan-Woo marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| cachetools==5.5.2 | ||
| certifi==2025.7.14 | ||
| charset-normalizer==3.4.2 | ||
| google-api-core==2.25.1 | ||
| google-api-python-client==2.177.0 | ||
| google-auth==2.40.3 | ||
| google-auth-httplib2==0.2.0 | ||
| googleapis-common-protos==1.70.0 | ||
| greenlet==3.2.3 | ||
| httplib2==0.22.0 | ||
| idna==3.10 | ||
| playwright==1.54.0 | ||
| proto-plus==1.26.1 | ||
| protobuf==6.31.1 | ||
| pyasn1==0.6.1 | ||
| pyasn1_modules==0.4.2 | ||
| pyee==13.0.0 | ||
| pyotp==2.9.0 | ||
| pyparsing==3.2.3 | ||
| requests==2.32.4 | ||
| rsa==4.9.1 | ||
| typing_extensions==4.14.1 | ||
| uritemplate==4.2.0 | ||
| urllib3==2.5.0 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The custom coding guidelines specify to avoid
if __name__ == "__main__"patterns in package code. This appears to be package code rather than a standalone script.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is a standalone script