diff --git a/modules/config.py b/modules/config.py index 14b04fb..9cccb4c 100644 --- a/modules/config.py +++ b/modules/config.py @@ -5,7 +5,8 @@ from dotenv import load_dotenv load_dotenv() # debugging flag -DEBUG = True +DEBUG = os.getenv("DEBUG_MODE", "false").lower() == "true" + # 🔧 Project Root PROJECT_ROOT = Path(__file__).resolve().parent.parent diff --git a/modules/description_utils.py b/modules/description_utils.py new file mode 100644 index 0000000..3671d10 --- /dev/null +++ b/modules/description_utils.py @@ -0,0 +1,67 @@ +""" +description_utils.py + +Utility functions for generating video descriptions dynamically using OpenAI's API. +Includes brand-aware humor, format-aware descriptions, and dynamic prompt generation. + +This module currently supports: +- Montage descriptions (fun, quirky, "Cool-Hand Gramps" themed) + +Author: Llama Chile Shop +Created: 2025-07-22 +""" + +import os +import random +import openai + +# 🛠 Global debug flag (imported by design elsewhere) +from modules.config import DEBUG + +# Set up OpenAI API key from environment +openai.api_key = os.getenv("OPENAI_API_KEY") + + +def generate_montage_description() -> str: + """ + Generates a creative, humorous description for a montage highlight video. + Leverages the "Cool-Hand Gramps" branding identity and inserts dynamic randomness + to keep each description fresh and engaging. + + Returns: + str: A YouTube/PeerTube-ready video description. + """ + # 🎲 Add entropy to reduce prompt caching / same-seed behavior + creativity_seed = random.randint(0, 999999) + + # 🧠 Base template for the prompt + prompt = f""" +You are a branding-savvy copywriter helping a YouTube gaming channel called "Llama Chile Shop" +run by a quirky and beloved senior gamer named "Gramps." Gramps is known for his calm demeanor, +sharp shooting, and whacky senile playstyle in Solo Zero Build Fortnite matches. His fans refer +to him as "Cool-Hand Gramps" because his heart rate doesn’t rise, even in intense firefights. + +Write a YouTube/PeerTube video description for a highlight montage from one of Gramps' livestreams. +Make it short, funny, and on-brand. Include emoticons and hashtags. Add a sentence encouraging viewers +to subscribe and check out the stream calendar. + +Entropy seed: {creativity_seed} +""" + + try: + response = openai.ChatCompletion.create( + model="gpt-4", + messages=[ + {"role": "system", "content": "You are a creative and humorous copywriter."}, + {"role": "user", "content": prompt} + ], + temperature=0.9, + max_tokens=250 + ) + return response.choices[0].message.content.strip() + + except Exception as e: + fallback = "Join Gramps for another action-packed Fortnite montage! Subscribe and watch live ➡ https://youtube.com/@llamachileshop 🎮🦙 #Fortnite #CoolHandGramps" + if DEBUG: + print(f"[ERROR] Failed to generate montage description: {e}") + return fallback diff --git a/modules/title_utils.py b/modules/title_utils.py index 65a634e..a57dc2f 100644 --- a/modules/title_utils.py +++ b/modules/title_utils.py @@ -111,5 +111,5 @@ def generate_montage_title(session_name: str) -> str: parts = session_name.split(".") year, month, day = map(int, parts[:3]) suffix = f" Video {parts[3]}" if len(parts) > 3 else "" - date_str = datetime(year, month, day).strftime("%B %-d, %Y") + date_str = datetime(year, month, day).strftime("%B %d, %Y").replace(" 0", " ") return f"#Fortnite #Solo #Zerobuild #Highlights with Gramps from {date_str}{suffix}" diff --git a/modules/yt_poster.py b/modules/yt_poster.py index e2f5343..2459327 100644 --- a/modules/yt_poster.py +++ b/modules/yt_poster.py @@ -1,88 +1,103 @@ +""" +yt_poster.py + +Handles video uploads to YouTube using the YouTube Data API. + +This module includes logic for setting titles, descriptions, tags, and +privacy status. It integrates with description generation tools and supports +automatic metadata based on the video type (e.g., montage). + +Requires authentication via OAuth 2.0 and expects a valid token.pickle file. + +Author: Llama Chile Shop +""" + import os -import pickle, logging -from pathlib import Path -from datetime import datetime - -from google_auth_oauthlib.flow import InstalledAppFlow -from google.auth.transport.requests import Request +from pathlib import Path from googleapiclient.discovery import build +from googleapiclient.errors import HttpError from googleapiclient.http import MediaFileUpload -from modules.title_utils import get_output_filename, generate_montage_title +from modules.title_utils import generate_montage_title, generate_output_filename +from modules.description_utils import generate_montage_description +from modules.config import DEBUG -# Define OAuth scopes and token paths -SCOPES = ["https://www.googleapis.com/auth/youtube.upload"] -TOKEN_PATH = Path("token.pickle") -CLIENT_SECRETS_FILE = Path("client_secrets.json") -def authenticate_youtube(): - """Handles YouTube OAuth flow and returns a service client.""" - creds = None +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. - if TOKEN_PATH.exists(): - with open(TOKEN_PATH, "rb") as token_file: - creds = pickle.load(token_file) + 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. - if not creds or not creds.valid: - if creds and creds.expired and creds.refresh_token: - creds.refresh(Request()) - else: - if not CLIENT_SECRETS_FILE.exists(): - raise FileNotFoundError("client_secrets.json not found.") - flow = InstalledAppFlow.from_client_secrets_file( - str(CLIENT_SECRETS_FILE), SCOPES - ) - creds = flow.run_local_server(port=0) - with open(TOKEN_PATH, "wb") as token_file: - pickle.dump(creds, token_file) + Returns: + str: URL of the uploaded YouTube video. + """ + 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()) - return build("youtube", "v3", credentials=creds) + # Construct tags and privacy status + tags = ["Fortnite", "Zero Build", "Solo", "Gramps", "CoolHandGramps"] + privacy_status = "private" if private else "public" -def generate_description(clip_path: Path, stream_date: datetime, is_montage: bool = False) -> str: - """Creates a dynamic and fun YouTube description.""" - kill_count_guess = sum(word.isdigit() for word in clip_path.stem.split()) - date_str = stream_date.strftime("%B %d, %Y") + # Authenticate + from authorize_youtube import get_authenticated_service + youtube = get_authenticated_service() - intro = "Gramps is back in Fortnite with another spicy highlight! 🦥" - if is_montage: - body = ( - f"This reel features an outrageous compilation of top plays from our {date_str} stream.\n" - f"{kill_count_guess} eliminations of stupendous magnitude that must be seen to be believed!" - ) - else: - body = ( - f"Recorded live on {date_str}, this clip captures one of many wild moments " - "from the battlefield. Grab your popcorn. 🎮" - ) - - hashtags = "#Fortnite #Gaming #SeniorGamer #LlamaChileShop #EpicMoments" - - return f"{intro}\n\n{body}\n\nSubscribe for more: https://youtube.com/@llamachileshop\n{hashtags}" - -def upload_to_youtube(video_path: Path, title: str, description: str, is_short: bool = False) -> str: - """Uploads the video to YouTube and returns the video URL.""" - youtube = authenticate_youtube() - - request_body = { - "snippet": { - "title": title, - "description": description, - "tags": ["Fortnite", "Gaming", "Senior Gamer", "LlamaChileShop"], - "categoryId": "20", # Gaming - }, - "status": { - "privacyStatus": "private", - "selfDeclaredMadeForKids": False, + body = { + "snippet": { + "title": title, + "description": description, + "tags": tags, + "categoryId": "20", # Gaming + }, + "status": { + "privacyStatus": privacy_status, + "selfDeclaredMadeForKids": False, + } } - } - media = MediaFileUpload(str(video_path), mimetype="video/mp4", resumable=True) + # media = MediaFileUpload(file_path, chunksize=-1, resumable=True) + media = MediaFileUpload(str(file_path), chunksize=-1, resumable=True) - request = youtube.videos().insert( - part="snippet,status", - body=request_body, - media_body=media - ) + 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)") - response = request.execute() - video_id = response["id"] - return f"https://youtu.be/{video_id}" + + request = youtube.videos().insert( + part="snippet,status", + body=body, + media_body=media + ) + + response = None + while response is None: + status, response = request.next_chunk() + 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']}" + + except HttpError as e: + print(f"❌ YouTube API error: {e}") + return "" + + except Exception as e: + print(f"❌ Unexpected error during upload: {e}") + return "" diff --git a/upload_youtube_montage.py b/upload_youtube_montage.py new file mode 100644 index 0000000..be41e41 --- /dev/null +++ b/upload_youtube_montage.py @@ -0,0 +1,62 @@ +""" +upload_montage_youtube.py + +Standalone entry point to upload a rendered Fortnite montage video to YouTube. +Assumes that the input video is a montage and therefore does NOT rely on a notes.* file. + +Handles: +- Validating input parameters (video path) +- Deriving vertical format from filename +- Generating dynamic description via OpenAI +- Uploading to YouTube with appropriate metadata +- Flagging video as private if DEBUG is enabled + +Author: Llama Chile Shop +Created: 2025-07-22 +""" + +import os +import sys +from pathlib import Path + +from modules.config import DEBUG +from modules.yt_poster import upload_video +from modules.description_utils import generate_montage_description + + +def main(): + """ + Entry point to handle YouTube upload of montage video. + Usage: + python upload_montage_youtube.py + """ + + if len(sys.argv) != 2: + print("Usage: python upload_montage_youtube.py ") + sys.exit(1) + + # Extract stream date from parent directory (Z:\2025.06.20) + video_path = Path(sys.argv[1]) + stream_date = video_path.parents[1].name # '2025.06.20' + + if not os.path.isfile(video_path): + print(f"[ERROR] File not found: {video_path}") + sys.exit(1) + + video_name = os.path.basename(video_path) + is_vertical = "-vert" in video_path.stem or "-vertical" in video_path.stem + + # Generate a dynamic, humorous montage description + description = generate_montage_description() + + # Upload the video to YouTube + upload_video( + file_path=video_path, + is_vertical=is_vertical, + stream_date=stream_date, + description=description, + private=DEBUG + ) + +if __name__ == "__main__": + main()