diff --git a/.gitignore b/.gitignore index 0cd1a8c..1851d35 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,59 @@ -# Byte-compiled / cache +# Python __pycache__/ *.py[cod] *.pyo *.pyd +*.so -# Virtual environment +# Virtual environments .venv/ env/ venv/ +ENV/ -# VS Code settings +# VSCode .vscode/ # OS files .DS_Store Thumbs.db -# Tokens and API keys +# Environment variables and secrets .env -token.pickle -token.zip -token (2).zip - -# Build artifacts -*.mp4 -*.mov -*.mp3 -*.zip -*.odt +client_secrets.json # Logs -logs/ *.log -# Assets not for versioning -assets/*.mp4 -assets/*.mp3 -assets/*.png -assets/*.otf +# Jupyter Notebook checkpoints +.ipynb_checkpoints/ -# Processed data -202*/**/rendered/ -202*/**/*.mp4 +# Compiled C extensions +*.c +*.o +*.obj +*.dll +*.a +*.lib +*.exp +*.pdb + +# Test and coverage +.coverage +.tox/ +.nox/ +.cache/ +pytest_cache/ +htmlcov/ + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +.eggs/ +MANIFEST + +# Misc +*.bak +*.swp +*.tmp \ No newline at end of file diff --git a/authorize_youtube.py b/authorize_youtube.py index d50f7c6..e6aad06 100644 --- a/authorize_youtube.py +++ b/authorize_youtube.py @@ -1,25 +1,61 @@ +""" +authorize_youtube.py + +Handles OAuth2 authorization for the YouTube Data API. + +This module loads the client_secrets.json file and generates an authorized +YouTube API service object for use by other modules. The token is cached +in token.pickle to avoid repeated authorization. + +Author: gramps@llamachile.shop +""" + import os -import sys +import pickle +from pathlib import Path -# Automatically locate this file's directory (e.g., \\chong\LCS\Videos\eklipse) -project_root = os.path.dirname(os.path.abspath(__file__)) -modules_dir = os.path.join(project_root, "modules") +from google.auth.transport.requests import Request +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build -# Add modules directory to the Python path -sys.path.insert(0, modules_dir) +# Scopes define what access is requested from the YouTube API +SCOPES = ["https://www.googleapis.com/auth/youtube.upload"] -# Change working directory so relative paths (like client_secrets.json) resolve -os.chdir(modules_dir) - -# Import from yt_poster in modules -from yt_poster import authenticate_youtube - -# Run the OAuth flow -print("🔐 Starting YouTube OAuth authorization...") +# Default token and client secret filenames +TOKEN_PATH = "token.pickle" +CLIENT_SECRET_FILE = "client_secrets.json" -try: - service = authenticate_youtube() - print("✅ YouTube authorization complete.") -except Exception as e: - print(f"❌ Authorization failed: {e}") +def get_authenticated_service(): + """ + Returns an authorized YouTube API client. + + If the token does not exist or is expired, initiates the OAuth flow. + Requires client_secrets.json in project root. + + Returns: + googleapiclient.discovery.Resource: Authenticated YouTube service + """ + creds = None + + # Check if token.pickle exists + if Path(TOKEN_PATH).exists(): + with open(TOKEN_PATH, "rb") as token: + creds = pickle.load(token) + + # If no valid creds, go through OAuth flow + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + print("🔐 Starting YouTube OAuth authorization...") + if not Path(CLIENT_SECRET_FILE).exists(): + raise FileNotFoundError(f"Missing required file: {CLIENT_SECRET_FILE}") + flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE, SCOPES) + creds = flow.run_local_server(port=0) + + # Save the credentials for future use + with open(TOKEN_PATH, "wb") as token: + pickle.dump(creds, token) + + return build("youtube", "v3", credentials=creds) diff --git a/modules/yt_poster.py b/modules/yt_poster.py index 2459327..8cb1f1c 100644 --- a/modules/yt_poster.py +++ b/modules/yt_poster.py @@ -13,44 +13,44 @@ Author: Llama Chile Shop """ import os -from pathlib import Path from googleapiclient.discovery import build from googleapiclient.errors import HttpError from googleapiclient.http import MediaFileUpload -from modules.title_utils import generate_montage_title, generate_output_filename +from modules.title_utils import generate_montage_title from modules.description_utils import generate_montage_description from modules.config import DEBUG +from dotenv import load_dotenv +from datetime import datetime +from pathlib import Path +load_dotenv() def upload_video(file_path: Path, is_vertical: bool, stream_date: str, description: str = None, private: bool = DEBUG) -> str: """ - Uploads a video file to YouTube. + Uploads a video to YouTube, assigns to playlist, sets recording date, and optionally uploads a thumbnail. Args: - file_path (str): Full path to the rendered video file. - is_vertical (bool): True if video is vertical format (9:16), else widescreen (16:9). - stream_date (str): Date of the stream in YYYY.MM.DD or YYYY.MM.DD.N format. + file_path (Path): Full path to the rendered video file. + is_vertical (bool): True if video is vertical. + stream_date (str): Stream session date in YYYY.MM.DD[.N] format. + description (str): Optional description. Generated if None. + private (bool): If True, uploads as private (used for debug mode). Returns: - str: URL of the uploaded YouTube video. + str: YouTube video URL. """ try: - # Build title I have this:"and description - file_path = str(file_path) - session_name = Path(file_path).parents[1].name - title = generate_montage_title(session_name) - - if not description: - description = str(generate_montage_description()) - - # Construct tags and privacy status - tags = ["Fortnite", "Zero Build", "Solo", "Gramps", "CoolHandGramps"] - privacy_status = "private" if private else "public" - - # Authenticate from authorize_youtube import get_authenticated_service youtube = get_authenticated_service() + title = generate_montage_title(stream_date) + if not description: + description = generate_montage_description() + + tags = ["Fortnite", "Zero Build", "Solo", "Gramps", "CoolHandGramps"] + privacy_status = "private" if private else "public" + + # Upload video body = { "snippet": { "title": title, @@ -64,21 +64,9 @@ def upload_video(file_path: Path, is_vertical: bool, stream_date: str, descripti } } - # media = MediaFileUpload(file_path, chunksize=-1, resumable=True) + print(f"📤 Uploading to YouTube: {file_path.name}") media = MediaFileUpload(str(file_path), chunksize=-1, resumable=True) - if DEBUG: - print("🔍 DEBUGGING upload_video") - print(f" • file_path: {file_path} ({type(file_path)})") - print(f" • is_vertical: {is_vertical}") - print(f" • stream_date: {stream_date}") - print(f" • private: {private}") - print(f" • title: {title}") - print(f" • description: {description}") - print(f" • tags: {tags}") - print(f" • categoryId: {'20'} (should be int or str)") - - request = youtube.videos().insert( part="snippet,status", body=body, @@ -91,8 +79,47 @@ def upload_video(file_path: Path, is_vertical: bool, stream_date: str, descripti if status: print(f"🟡 Uploading: {int(status.progress() * 100)}%") - print(f"✅ Upload complete: https://youtu.be/{response['id']}") - return f"https://youtu.be/{response['id']}" + video_id = response["id"] + video_url = f"https://youtu.be/{video_id}" + print(f"✅ Upload complete: {video_url}") + + # Add to playlist + playlist_id = os.getenv("YT_PLAYLIST_ID_SHORTS" if is_vertical else "YT_PLAYLIST_ID_CLIPS") + if playlist_id: + youtube.playlistItems().insert( + part="snippet", + body={ + "snippet": { + "playlistId": playlist_id, + "resourceId": { + "kind": "youtube#video", + "videoId": video_id + } + } + } + ).execute() + print(f"✅ Added to playlist: {playlist_id}") + + # Set recording date + parts = stream_date.split(".") + date_obj = datetime(int(parts[0]), int(parts[1]), int(parts[2])) + recording_date = date_obj.strftime("%Y-%m-%dT00:00:00Z") + + youtube.videos().update( + part="recordingDetails", + body={ + "id": video_id, + "recordingDetails": { + "recordingDate": recording_date + } + } + ).execute() + print(f"✅ Recording date set: {recording_date}") + + # TODO: Thumbnail upload (stub) + # generate_thumbnail(file_path) → upload via thumbnails().set() + + return video_url except HttpError as e: print(f"❌ YouTube API error: {e}") diff --git a/upload_youtube_montage.py b/upload_youtube_montage.py index be41e41..43c7008 100644 --- a/upload_youtube_montage.py +++ b/upload_youtube_montage.py @@ -22,6 +22,7 @@ from pathlib import Path from modules.config import DEBUG from modules.yt_poster import upload_video from modules.description_utils import generate_montage_description +from authorize_youtube import get_authenticated_service def main():