Initial YouTube description generation and authentication — work in progress
This commit is contained in:
@ -5,7 +5,8 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# debugging flag
|
# debugging flag
|
||||||
DEBUG = True
|
DEBUG = os.getenv("DEBUG_MODE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
# 🔧 Project Root
|
# 🔧 Project Root
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|||||||
67
modules/description_utils.py
Normal file
67
modules/description_utils.py
Normal file
@ -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
|
||||||
@ -111,5 +111,5 @@ def generate_montage_title(session_name: str) -> str:
|
|||||||
parts = session_name.split(".")
|
parts = session_name.split(".")
|
||||||
year, month, day = map(int, parts[:3])
|
year, month, day = map(int, parts[:3])
|
||||||
suffix = f" Video {parts[3]}" if len(parts) > 3 else ""
|
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}"
|
return f"#Fortnite #Solo #Zerobuild #Highlights with Gramps from {date_str}{suffix}"
|
||||||
|
|||||||
@ -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 os
|
||||||
import pickle, logging
|
from pathlib import Path
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
||||||
from google.auth.transport.requests import Request
|
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
from googleapiclient.http import MediaFileUpload
|
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():
|
def upload_video(file_path: Path, is_vertical: bool, stream_date: str, description: str = None, private: bool = DEBUG) -> str:
|
||||||
"""Handles YouTube OAuth flow and returns a service client."""
|
"""
|
||||||
creds = None
|
Uploads a video file to YouTube.
|
||||||
|
|
||||||
if TOKEN_PATH.exists():
|
Args:
|
||||||
with open(TOKEN_PATH, "rb") as token_file:
|
file_path (str): Full path to the rendered video file.
|
||||||
creds = pickle.load(token_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:
|
Returns:
|
||||||
if creds and creds.expired and creds.refresh_token:
|
str: URL of the uploaded YouTube video.
|
||||||
creds.refresh(Request())
|
"""
|
||||||
else:
|
try:
|
||||||
if not CLIENT_SECRETS_FILE.exists():
|
# Build title I have this:"and description
|
||||||
raise FileNotFoundError("client_secrets.json not found.")
|
file_path = str(file_path)
|
||||||
flow = InstalledAppFlow.from_client_secrets_file(
|
session_name = Path(file_path).parents[1].name
|
||||||
str(CLIENT_SECRETS_FILE), SCOPES
|
title = generate_montage_title(session_name)
|
||||||
)
|
|
||||||
creds = flow.run_local_server(port=0)
|
if not description:
|
||||||
with open(TOKEN_PATH, "wb") as token_file:
|
description = str(generate_montage_description())
|
||||||
pickle.dump(creds, token_file)
|
|
||||||
|
|
||||||
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:
|
# Authenticate
|
||||||
"""Creates a dynamic and fun YouTube description."""
|
from authorize_youtube import get_authenticated_service
|
||||||
kill_count_guess = sum(word.isdigit() for word in clip_path.stem.split())
|
youtube = get_authenticated_service()
|
||||||
date_str = stream_date.strftime("%B %d, %Y")
|
|
||||||
|
|
||||||
intro = "Gramps is back in Fortnite with another spicy highlight! 🦥"
|
body = {
|
||||||
if is_montage:
|
"snippet": {
|
||||||
body = (
|
"title": title,
|
||||||
f"This reel features an outrageous compilation of top plays from our {date_str} stream.\n"
|
"description": description,
|
||||||
f"{kill_count_guess} eliminations of stupendous magnitude that must be seen to be believed!"
|
"tags": tags,
|
||||||
)
|
"categoryId": "20", # Gaming
|
||||||
else:
|
},
|
||||||
body = (
|
"status": {
|
||||||
f"Recorded live on {date_str}, this clip captures one of many wild moments "
|
"privacyStatus": privacy_status,
|
||||||
"from the battlefield. Grab your popcorn. 🎮"
|
"selfDeclaredMadeForKids": False,
|
||||||
)
|
}
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
if DEBUG:
|
||||||
part="snippet,status",
|
print("🔍 DEBUGGING upload_video")
|
||||||
body=request_body,
|
print(f" • file_path: {file_path} ({type(file_path)})")
|
||||||
media_body=media
|
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"]
|
request = youtube.videos().insert(
|
||||||
return f"https://youtu.be/{video_id}"
|
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 ""
|
||||||
|
|||||||
62
upload_youtube_montage.py
Normal file
62
upload_youtube_montage.py
Normal file
@ -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 <video_path>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print("Usage: python upload_montage_youtube.py <path_to_rendered_video>")
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user