Skip to content
Merged
Show file tree
Hide file tree
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 Jun 20, 2025
00c4eba
Complete Playwright YouTube downloader implementation
Copilot Jun 20, 2025
b889629
Simplify Playwright downloader to use YouTube Studio interface
Copilot Jun 20, 2025
9520d71
Add login demonstration with dummy credentials
Copilot Jun 21, 2025
2fce774
Add real credential testing and demonstration scripts
Copilot Jun 21, 2025
270c004
Add comprehensive verification testing for YouTube Studio channel access
Copilot Jun 21, 2025
5fb4ae7
Improve login method to handle 2FA removal and device verification re…
Copilot Jun 21, 2025
8898453
Confirm 2FA removal and add credential testing scripts
Copilot Jun 21, 2025
dd52aac
Successfully test video download authentication - ready for device ve…
Copilot Jun 21, 2025
b63134b
Improve device verification handling in authentication flow
Copilot Jun 21, 2025
62913f1
Implement MCP Playwright-based YouTube video downloader
Copilot Jun 21, 2025
ee8b2d2
Test 2FA status and authentication flow - confirm 2FA removed but Goo…
Copilot Jun 22, 2025
8e5a13c
Revert to clean slate (commit e32ad19) as requested
Copilot Jun 24, 2025
367986c
added playwright youtube download
Jonathan-Woo Jul 24, 2025
673a95a
removed unused import
Jonathan-Woo Jul 24, 2025
ed80efe
isort fix
Jonathan-Woo Jul 24, 2025
f568e95
removed unused yt utils
Jonathan-Woo Jul 24, 2025
35dff55
added requirements
Jonathan-Woo Jul 31, 2025
6cb680a
added more prints for responsiveness
Jonathan-Woo Jul 31, 2025
c742667
added my_secrets_example.py to provide a template for users to fill i…
Jonathan-Woo Jul 31, 2025
26e7b97
removed playlist filtering
Jonathan-Woo Jul 31, 2025
98e8501
updated credentials from env variables to my_secrets
Jonathan-Woo Jul 31, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions src/ac_training_lab/video_editing/download.py
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__":
Copy link

Copilot AI Jul 31, 2025

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.

Copilot generated this review using guidance from repository custom instructions.
Copy link
Contributor

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

main()
8 changes: 8 additions & 0 deletions src/ac_training_lab/video_editing/my_secrets_example.py
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 = ""
24 changes: 24 additions & 0 deletions src/ac_training_lab/video_editing/requirements.txt
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
Loading