🛠️ Initial YouTube upload and description generation: OAuth, montage flow, thumbnail setup, env config. Work in progress by gramps@llamachile.shop
This commit is contained in:
61
.gitignore
vendored
61
.gitignore
vendored
@ -1,44 +1,59 @@
|
|||||||
# Byte-compiled / cache
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.pyo
|
*.pyo
|
||||||
*.pyd
|
*.pyd
|
||||||
|
*.so
|
||||||
|
|
||||||
# Virtual environment
|
# Virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
# VS Code settings
|
# VSCode
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Tokens and API keys
|
# Environment variables and secrets
|
||||||
.env
|
.env
|
||||||
token.pickle
|
client_secrets.json
|
||||||
token.zip
|
|
||||||
token (2).zip
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
*.mp4
|
|
||||||
*.mov
|
|
||||||
*.mp3
|
|
||||||
*.zip
|
|
||||||
*.odt
|
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Assets not for versioning
|
# Jupyter Notebook checkpoints
|
||||||
assets/*.mp4
|
.ipynb_checkpoints/
|
||||||
assets/*.mp3
|
|
||||||
assets/*.png
|
|
||||||
assets/*.otf
|
|
||||||
|
|
||||||
# Processed data
|
# Compiled C extensions
|
||||||
202*/**/rendered/
|
*.c
|
||||||
202*/**/*.mp4
|
*.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
|
||||||
@ -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 os
|
||||||
import sys
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Automatically locate this file's directory (e.g., \\chong\LCS\Videos\eklipse)
|
from google.auth.transport.requests import Request
|
||||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
modules_dir = os.path.join(project_root, "modules")
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
# Add modules directory to the Python path
|
# Scopes define what access is requested from the YouTube API
|
||||||
sys.path.insert(0, modules_dir)
|
SCOPES = ["https://www.googleapis.com/auth/youtube.upload"]
|
||||||
|
|
||||||
# Change working directory so relative paths (like client_secrets.json) resolve
|
# Default token and client secret filenames
|
||||||
os.chdir(modules_dir)
|
TOKEN_PATH = "token.pickle"
|
||||||
|
CLIENT_SECRET_FILE = "client_secrets.json"
|
||||||
|
|
||||||
# Import from yt_poster in modules
|
|
||||||
from yt_poster import authenticate_youtube
|
|
||||||
|
|
||||||
# Run the OAuth flow
|
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...")
|
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)
|
||||||
|
|
||||||
try:
|
return build("youtube", "v3", credentials=creds)
|
||||||
service = authenticate_youtube()
|
|
||||||
print("✅ YouTube authorization complete.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"❌ Authorization failed: {e}")
|
|
||||||
|
|||||||
@ -13,44 +13,44 @@ Author: Llama Chile Shop
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
from googleapiclient.errors import HttpError
|
from googleapiclient.errors import HttpError
|
||||||
from googleapiclient.http import MediaFileUpload
|
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.description_utils import generate_montage_description
|
||||||
from modules.config import DEBUG
|
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:
|
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:
|
Args:
|
||||||
file_path (str): Full path to the rendered video file.
|
file_path (Path): Full path to the rendered video file.
|
||||||
is_vertical (bool): True if video is vertical format (9:16), else widescreen (16:9).
|
is_vertical (bool): True if video is vertical.
|
||||||
stream_date (str): Date of the stream in YYYY.MM.DD or YYYY.MM.DD.N format.
|
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:
|
Returns:
|
||||||
str: URL of the uploaded YouTube video.
|
str: YouTube video URL.
|
||||||
"""
|
"""
|
||||||
try:
|
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
|
from authorize_youtube import get_authenticated_service
|
||||||
youtube = 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 = {
|
body = {
|
||||||
"snippet": {
|
"snippet": {
|
||||||
"title": title,
|
"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)
|
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(
|
request = youtube.videos().insert(
|
||||||
part="snippet,status",
|
part="snippet,status",
|
||||||
body=body,
|
body=body,
|
||||||
@ -91,8 +79,47 @@ def upload_video(file_path: Path, is_vertical: bool, stream_date: str, descripti
|
|||||||
if status:
|
if status:
|
||||||
print(f"🟡 Uploading: {int(status.progress() * 100)}%")
|
print(f"🟡 Uploading: {int(status.progress() * 100)}%")
|
||||||
|
|
||||||
print(f"✅ Upload complete: https://youtu.be/{response['id']}")
|
video_id = response["id"]
|
||||||
return f"https://youtu.be/{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:
|
except HttpError as e:
|
||||||
print(f"❌ YouTube API error: {e}")
|
print(f"❌ YouTube API error: {e}")
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from pathlib import Path
|
|||||||
from modules.config import DEBUG
|
from modules.config import DEBUG
|
||||||
from modules.yt_poster import upload_video
|
from modules.yt_poster import upload_video
|
||||||
from modules.description_utils import generate_montage_description
|
from modules.description_utils import generate_montage_description
|
||||||
|
from authorize_youtube import get_authenticated_service
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
Reference in New Issue
Block a user