moved youtube scirpts into there own folder
This commit is contained in:
1 parent
8bf85fea2e
commit
afe6490d20
15 files changed
+127
-102
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# package/youtube/__init__.py
|
||||
|
||||
# Expose components from individual modules using relative imports
|
||||
from .find_tags import find_tags
|
||||
from .hashtags import get_top_nouns
|
||||
from .shorts import fit_to_9_16_letterbox
|
||||
from .uploader import upload_video, CategoryId
|
||||
|
||||
# Explicitly define what is exposed when importing the subpackage
|
||||
__all__ = [
|
||||
"find_tags",
|
||||
"get_top_nouns",
|
||||
"fit_to_9_16_letterbox",
|
||||
"upload_video",
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.auth.transport.requests import Request
|
||||
|
||||
CREDENTIALS = None
|
||||
|
||||
def load_credentials() -> Credentials:
|
||||
"""Load credentials from secrets.json"""
|
||||
try:
|
||||
with open('secrets.json', 'r') as f:
|
||||
creds_data = json.load(f)
|
||||
|
||||
credentials = Credentials(
|
||||
token=creds_data['token'],
|
||||
refresh_token=creds_data['refresh_token'],
|
||||
token_uri=creds_data['token_uri'],
|
||||
client_id=creds_data['client_id'],
|
||||
client_secret=creds_data['client_secret'],
|
||||
scopes=creds_data['scopes']
|
||||
)
|
||||
|
||||
# Refresh token if expired
|
||||
if credentials.expired:
|
||||
credentials.refresh(Request())
|
||||
|
||||
return credentials
|
||||
except FileNotFoundError:
|
||||
print("Error: secrets.json not found! Please run setup.py first.")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error loading credentials: {str(e)}")
|
||||
return None
|
||||
|
||||
CREDENTIALS = load_credentials()
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
from typing import Any
|
||||
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
import package.youtube.config as config
|
||||
|
||||
def get_my_uploads_playlist_id(youtube: build) -> Any:
|
||||
"""Retrieves the system upload playlist ID for the authenticated user's channel."""
|
||||
try:
|
||||
# mine=True automatically references the authorized account
|
||||
request = youtube.channels().list(part="contentDetails", mine=True)
|
||||
response = request.execute()
|
||||
|
||||
if "items" in response and len(response['items']) > 0:
|
||||
return response['items'][0]['contentDetails']['relatedPlaylists']['uploads']
|
||||
else:
|
||||
print("No channel found for these credentials.")
|
||||
return None
|
||||
except HttpError as e:
|
||||
print(f"API Error retrieving channel details: {str(e)}")
|
||||
return None
|
||||
|
||||
def scan_channel_videos_for_tag(youtube: build, uploads_playlist_id: str, target_tag: str) -> list:
|
||||
"""
|
||||
Loops through all channel video uploads and filters those possessing the target tag.
|
||||
"""
|
||||
target_tag_lower = target_tag.lower()
|
||||
all_videos_count = 0
|
||||
matched_videos = []
|
||||
next_page_token = None
|
||||
|
||||
print("Beginning channel scan...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Step A: Retrieve a batch of video IDs from the uploads playlist container
|
||||
playlist_request = youtube.playlistItems().list(
|
||||
part="snippet",
|
||||
playlistId=uploads_playlist_id,
|
||||
maxResults=50,
|
||||
pageToken=next_page_token
|
||||
)
|
||||
playlist_response = playlist_request.execute()
|
||||
|
||||
video_ids_batch = [
|
||||
item['snippet']['resourceId']['videoId']
|
||||
for item in playlist_response.get("items", [])
|
||||
]
|
||||
|
||||
if not video_ids_batch:
|
||||
break
|
||||
|
||||
all_videos_count += len(video_ids_batch)
|
||||
|
||||
# Step B: Pass batch to videos().list to extract metadata details (including tags)
|
||||
video_request = youtube.videos().list(
|
||||
part="snippet",
|
||||
id=",".join(video_ids_batch)
|
||||
)
|
||||
video_response = video_request.execute()
|
||||
|
||||
for video in video_response.get("items", []):
|
||||
title = video['snippet']['title']
|
||||
video_id = video['id']
|
||||
# Tags are optional fields on YouTube; default to an empty list if absent
|
||||
tags = video['snippet'].get("tags", [])
|
||||
|
||||
# Normalize tags to lowercase for clean matching evaluation
|
||||
tags_lower = [tag.lower() for tag in tags]
|
||||
|
||||
if target_tag_lower in tags_lower:
|
||||
matched_videos.append({
|
||||
"id": video_id,
|
||||
"title": title,
|
||||
"tags": tags
|
||||
})
|
||||
print(f"🔍 Found Match: '{title}' (ID: {video_id})")
|
||||
|
||||
# Check if another page token exists, if not break the pagination loop
|
||||
next_page_token = playlist_response.get("nextPageToken")
|
||||
if not next_page_token:
|
||||
break
|
||||
|
||||
except HttpError as e:
|
||||
print(f"An error occurred while fetching video batches: {str(e)}")
|
||||
break
|
||||
|
||||
# Summary reporting
|
||||
print("\n" + "="*40)
|
||||
print(f"Scan complete. Analyzed {all_videos_count} total videos.")
|
||||
print(f"Found {len(matched_videos)} videos containing the '{target_tag}' tag.")
|
||||
print("="*40)
|
||||
|
||||
return matched_videos
|
||||
|
||||
def find_tags():
|
||||
|
||||
if not config.CREDENTIALS:
|
||||
return
|
||||
|
||||
youtube = build('youtube', 'v3', credentials=config.CREDENTIALS)
|
||||
|
||||
# 1. Fetch your dynamic uploads playlist pointer
|
||||
uploads_id = get_my_uploads_playlist_id(youtube)
|
||||
|
||||
if uploads_id:
|
||||
print(f"Target Uploads Playlist ID: {uploads_id}")
|
||||
|
||||
# 2. Run the iterative match parser targeting the 'clips' keyword tag
|
||||
target_keyword = "clips"
|
||||
results = scan_channel_videos_for_tag(youtube, uploads_id, target_keyword)
|
||||
|
||||
# 3. Print out a neat clean list of matches
|
||||
if results:
|
||||
print(f"\n--- List of matching videos for tag '{target_keyword}': ---")
|
||||
for index, item in enumerate(results, start=1):
|
||||
print(f"{index}. {item['title']} -> https://youtu.be{item['id']}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
find_tags()
|
||||
Executable
+104
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
import json
|
||||
import nltk
|
||||
import string
|
||||
from collections import Counter
|
||||
from nltk.corpus import stopwords
|
||||
from nltk.tokenize import word_tokenize
|
||||
|
||||
# Download necessary NLTK data modules
|
||||
nltk.download('punkt', quiet=True)
|
||||
nltk.download('stopwords', quiet=True)
|
||||
nltk.download('punkt_tab', quiet=True)
|
||||
nltk.download('averaged_perceptron_tagger', quiet=True) # Required for POS tagging
|
||||
nltk.download('averaged_perceptron_tagger_eng', quiet=True)
|
||||
|
||||
def extract_text_from_srt(file_path: str) -> str:
|
||||
"""
|
||||
Extracts the text from the srt file.
|
||||
|
||||
Args:
|
||||
file_path (str): is the path to the str file.
|
||||
|
||||
Returns:
|
||||
returns (str): returns the cleaned up text.
|
||||
"""
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
content = file.read()
|
||||
# Remove SRT timestamps and sequence numbers
|
||||
clean_text = re.sub(r'\d+\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}\n', '', content)
|
||||
clean_text = re.sub(r'\d+', '', clean_text)
|
||||
return clean_text
|
||||
|
||||
def extract_text_from_json(file_path: str) -> str:
|
||||
"""
|
||||
Extracts the text from the json file.
|
||||
|
||||
Args:
|
||||
file_path (str): is the path to the json file.
|
||||
|
||||
Returns:
|
||||
returns (str): returns the cleaned up text.
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = json.load(file)
|
||||
|
||||
messages = []
|
||||
for comment in data.get("comments", []):
|
||||
message_text = comment.get("message", {}).get("body", "")
|
||||
messages.append(message_text)
|
||||
|
||||
# Return a single merged string of all chat text
|
||||
return " ".join(messages)
|
||||
|
||||
def get_top_nouns(file_path: str, top_n: int = 10) -> list:
|
||||
"""
|
||||
Gets the top number of nouns based on past (top_n) arg.
|
||||
|
||||
Args:
|
||||
file_path (str): is the path to the text file.
|
||||
top_n (int): is the number of nouns to return.
|
||||
|
||||
Returns:
|
||||
returns (list): returns a list of nouns.
|
||||
"""
|
||||
# 1. Extract raw text
|
||||
if file_path.endswith(".srt"):
|
||||
raw_text = extract_text_from_srt(file_path)
|
||||
else:
|
||||
raw_text = extract_text_from_json(file_path)
|
||||
|
||||
# 2. Basic cleanup (Keep original case for proper noun accuracy)
|
||||
# Strip basic punctuation but leave words intact
|
||||
clean_text = raw_text.translate(str.maketrans('', '', string.punctuation))
|
||||
|
||||
# 3. Tokenize words
|
||||
words = word_tokenize(clean_text)
|
||||
|
||||
# 4. Part-of-Speech Tagging
|
||||
tagged_words = nltk.pos_tag(words)
|
||||
|
||||
# 5. Filter for Nouns (NN = Singular Noun, NNP = Proper Noun, NNS = Plural Noun)
|
||||
stop_words = set(stopwords.words('english'))
|
||||
nouns = []
|
||||
|
||||
for word, tag in tagged_words:
|
||||
word_lower = word.lower()
|
||||
# Filter out short fragments and standard stopwords
|
||||
if tag in ['NN', 'NNP', 'NNS'] and len(word_lower) > 2 and word_lower not in stop_words:
|
||||
nouns.append(word_lower)
|
||||
|
||||
# 6. Count frequencies
|
||||
noun_counts = Counter(nouns)
|
||||
return [noun for noun, count in noun_counts.most_common(top_n)]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
# Replace 'your_video.srt' with the path to your file
|
||||
results = get_top_nouns('download/videos/2813112936/2813112936.srt', top_n=10)
|
||||
print("Trending Hashtags SRT:", results)
|
||||
|
||||
results = get_top_nouns("download/videos/2813112936/2813112936_chat.json", top_n=10)
|
||||
print("Trending Hashtags JSON:", results)
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import json
|
||||
import package.utils as utils
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from moviepy import VideoFileClip, ColorClip, CompositeVideoClip
|
||||
from moviepy.video.VideoClip import TextClip
|
||||
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
def get_video_info(input_path: Path) -> tuple:
|
||||
"""Uses ffprobe to instantly read input video dimensions and frame rate."""
|
||||
cmd = f"ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate -of json {input_path}"
|
||||
# Run command and capture output (assumes linux.run_command prints or you use subprocess)
|
||||
import subprocess
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
#linux.run_command(cmd)
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
stream = data['streams'][0]
|
||||
w = int(stream['width'])
|
||||
h = int(stream['height'])
|
||||
# Convert fractional FPS string (e.g. "60/1" or "30000/1001") to float
|
||||
fps_parts = stream['r_frame_rate'].split('/')
|
||||
fps = float(fps_parts[0]) / float(fps_parts[1]) if len(fps_parts) > 1 else float(fps_parts[0])
|
||||
return w, h, fps
|
||||
except Exception:
|
||||
return 1920, 1080, 60.0 # Safe defaults if probe fails
|
||||
|
||||
def fit_to_9_16_letterbox(input_path: str, top_text: str = "TOP TEXT", bottom_text: str = "BOTTOM TEXT", use_blur: bool = True, force: bool = False):
|
||||
threads = "8"
|
||||
input_file = Path(input_path)
|
||||
output_suffix = "gaussian_9_16" if use_blur else "black_9_16"
|
||||
output_path = input_file.parent / f"{input_file.stem}_{output_suffix}{input_file.suffix}"
|
||||
|
||||
if os.path.exists(output_path):
|
||||
if not force:
|
||||
print(f"❌ Error: Short video already exists: {output_path}")
|
||||
return
|
||||
else:
|
||||
os.remove(output_path)
|
||||
|
||||
# 1. Probe input metadata instantly
|
||||
orig_w, orig_h, fps = get_video_info(input_file)
|
||||
canvas_w = 1080
|
||||
canvas_h = 1920
|
||||
|
||||
print("✍️ Generating text overlay graphics via MoviePy...")
|
||||
# Render static images for text instead of running a video context
|
||||
title_clip = TextClip(
|
||||
text=top_text, font_size=55, color="white", font="DejaVuSans-Bold",
|
||||
text_align="center", size=(canvas_w - 100, 300), method="caption"
|
||||
)
|
||||
bottom_clip = TextClip(
|
||||
text=bottom_text, font_size=55, color="white", font="DejaVuSans-Bold",
|
||||
text_align="center", size=(canvas_w - 100, 300), method="caption"
|
||||
)
|
||||
|
||||
# Save text layers to temporary PNGs
|
||||
top_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
||||
bottom_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
|
||||
title_clip.save_frame(top_png)
|
||||
bottom_clip.save_frame(bottom_png)
|
||||
|
||||
title_clip.close()
|
||||
bottom_clip.close()
|
||||
|
||||
print("🎬 Dispatching compilation workload to FFmpeg filtergraph...")
|
||||
|
||||
# 2. Build the complex FFmpeg filtergraph
|
||||
# [0:v] is the raw input video stream
|
||||
filter_complex = []
|
||||
|
||||
if use_blur:
|
||||
# Scale height to 1920, crop center 1080x1920, apply fast boxblur (power of 3 approximates Gaussian)
|
||||
filter_complex.append(
|
||||
f"[0:v]scale=-1:{canvas_h},crop={canvas_w}:{canvas_h}:(iw-{canvas_w})/2:0,boxblur=luma_radius=35:luma_power=3[bg];"
|
||||
)
|
||||
else:
|
||||
# Generate a pure black background canvas matching video frame specs
|
||||
filter_complex.append(
|
||||
f"color=c=black:s={canvas_w}x{canvas_h}:r={fps}[bg];"
|
||||
)
|
||||
|
||||
# Scale the foreground video to a clean 1080 width, keeping aspect ratio
|
||||
filter_complex.append(
|
||||
f"[0:v]scale={canvas_w}:-1[fg];"
|
||||
)
|
||||
|
||||
# Layer composition chain:
|
||||
# Overlay 1: Put scaled foreground onto background (centered vertically)
|
||||
filter_complex.append(
|
||||
f"[bg][fg]overlay=0:(H-h)/2[tmp1];"
|
||||
)
|
||||
# Overlay 2: Drop top text asset onto position Y=180
|
||||
filter_complex.append(
|
||||
f"[tmp1][1:v]overlay=(W-w)/2:180[tmp2];"
|
||||
)
|
||||
# Overlay 3: Drop bottom text asset onto position Y=1430
|
||||
filter_complex.append(
|
||||
f"[tmp2][2:v]overlay=(W-w)/2:1430[finalv]"
|
||||
)
|
||||
|
||||
filter_graph = "".join(filter_complex)
|
||||
|
||||
# 3. Execute the native assembly command
|
||||
# -map_chapters -1 -sn: Strips unnecessary metadata chunks instantly
|
||||
# -c:a copy: Safely pulls original digital audio directly without decompression cycles
|
||||
# -threads 0: Forces FFmpeg to auto-consume all available processing cores
|
||||
ffmpeg_cmd = (
|
||||
f'ffmpeg -y -v error -i "{input_file}" -i "{top_png}" -i "{bottom_png}" '
|
||||
f'-filter_complex "{filter_graph}" '
|
||||
f'-map "[finalv]" -map 0:a? -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p '
|
||||
f'-c:a copy -map_chapters -1 -sn -threads {threads} "{output_path}"'
|
||||
)
|
||||
|
||||
try:
|
||||
utils.run_command(ffmpeg_cmd)
|
||||
print(f"🎉 High-speed processing complete! Video saved to: {output_path}")
|
||||
finally:
|
||||
# Clean up temporary PNG picture files safely
|
||||
for path in (top_png, bottom_png):
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import time
|
||||
|
||||
start_time = time.perf_counter()
|
||||
|
||||
creator_name = "greenskiesbluegrass"
|
||||
top_txt = "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp"
|
||||
bottom_txt = f"Clipped By: {creator_name}."
|
||||
fit_to_9_16_letterbox("download/clips/AbnegateAgitatedGrassPJSalt/AbnegateAgitatedGrassPJSalt.mp4", top_txt, bottom_txt, True, True)
|
||||
|
||||
end_time = time.perf_counter()
|
||||
execution_time = end_time - start_time
|
||||
|
||||
print(f"The function took {execution_time:.6f} seconds to complete.")
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import argparse
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaFileUpload
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
import package.youtube.config as config
|
||||
|
||||
class CategoryId(Enum):
|
||||
"""Official YouTube Category IDs for API Uploads."""
|
||||
|
||||
FILM_AND_ANIMATION = "1"
|
||||
AUTOS_AND_VEHICLES = "2"
|
||||
MUSIC = "10"
|
||||
PETS_AND_ANIMALS = "15"
|
||||
SPORTS = "17"
|
||||
TRAVEL_AND_EVENTS = "19"
|
||||
GAMING = "20"
|
||||
PEOPLE_AND_BLOGS = "22"
|
||||
COMEDY = "23"
|
||||
ENTERTAINMENT = "24"
|
||||
NEWS_AND_POLITICS = "25"
|
||||
HOWTO_AND_STYLE = "26"
|
||||
EDUCATION = "27"
|
||||
SCIENCE_AND_TECHNOLOGY = "28"
|
||||
NONPROFITS_AND_ACTIVISM = "29"
|
||||
|
||||
def upload_video(file_path: str, title: str, category: CategoryId, description: str = "", privacyStatus: str = 'private', tags: list = None, release_time: datetime = None) -> bool:
|
||||
"""
|
||||
Upload a video to YouTube
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the video file
|
||||
title (str): Title of the Video
|
||||
categoryId (str): categoryId of the video
|
||||
description (str): Video description
|
||||
privacyStatus (str): privacyStatus of the video
|
||||
tags (str): tags to be use on the video
|
||||
release_time (datetime): Optional timezone-aware UTC datetime object for timed release
|
||||
|
||||
schedule_date = datetime.now(timezone.utc) + timedelta(days=2)
|
||||
"""
|
||||
if tags is None:
|
||||
tags = []
|
||||
|
||||
try:
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
print(f"❌ Error: File not found: {file_path}")
|
||||
return False
|
||||
|
||||
# Load credentials
|
||||
if not config.CREDENTIALS:
|
||||
return False
|
||||
|
||||
# Create YouTube API client
|
||||
youtube = build('youtube', 'v3', credentials=config.CREDENTIALS)
|
||||
|
||||
# Configure the status object dynamically
|
||||
status_body = {
|
||||
'selfDeclaredMadeForKids': False
|
||||
}
|
||||
|
||||
if release_time is not None:
|
||||
if release_time.tzinfo is None:
|
||||
release_time = release_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
# If release_time is passed, YouTube forces privacyStatus to 'private'
|
||||
status_body['privacyStatus'] = 'private'
|
||||
status_body['publishAt'] = release_time.strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
print(f"Configuring timed release for: {status_body['publishAt']}")
|
||||
else:
|
||||
# Standard immediate upload
|
||||
status_body['privacyStatus'] = privacyStatus
|
||||
print(f"Configuring immediate upload with status: {privacyStatus}")
|
||||
|
||||
# Prepare the video upload request
|
||||
body = {
|
||||
'snippet': {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'tags': tags,
|
||||
'categoryId': category.value
|
||||
},
|
||||
'status': status_body
|
||||
}
|
||||
|
||||
# Create media file upload
|
||||
media = MediaFileUpload(
|
||||
file_path,
|
||||
chunksize=1024*1024,
|
||||
resumable=True
|
||||
)
|
||||
|
||||
# Create the video insert request
|
||||
insert_request = youtube.videos().insert(
|
||||
part=','.join(body.keys()),
|
||||
body=body,
|
||||
media_body=media
|
||||
)
|
||||
|
||||
print(f"Starting upload for '{title}'...")
|
||||
response = None
|
||||
while response is None:
|
||||
status, response = insert_request.next_chunk()
|
||||
if status:
|
||||
print(f"Uploaded {int(status.progress() * 100)}%")
|
||||
|
||||
print(f"\nUpload Complete!")
|
||||
print(f"Video ID: {response['id']}")
|
||||
print(f"Title: {response['snippet']['title']}")
|
||||
print(f"URL: https://youtu.be/{response['id']}")
|
||||
|
||||
# Output confirmation based on what was chosen
|
||||
if 'publishAt' in response['status']:
|
||||
print(f"Scheduled Release Time: {response['status']['publishAt']}")
|
||||
else:
|
||||
print(f"Current Privacy Status: {response['status']['privacyStatus']}")
|
||||
|
||||
return True
|
||||
|
||||
except HttpError as e:
|
||||
print(f"An HTTP error occurred: {str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {str(e)}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Upload a video to YouTube')
|
||||
parser.add_argument('--file', required=True, help='Path to the video file')
|
||||
parser.add_argument('--title', required=True, help='Title of the video')
|
||||
parser.add_argument('--category', default='PEOPLE_AND_BLOGS', choices=[c.name for c in CategoryId], help='Video category genre')
|
||||
parser.add_argument('--description', default='', help='Video description text')
|
||||
parser.add_argument('--privacy', default='private', choices=['public', 'private', 'unlisted'], help='Video privacy settings')
|
||||
|
||||
# ADDED: Feature parsing to easily pass tags from the CLI split by commas
|
||||
parser.add_argument('--tags', default='', help='Comma-separated tags list (e.g. "python,coding,api")')
|
||||
|
||||
# ADDED: Option to provide a scheduled upload timestamp natively from CLI
|
||||
parser.add_argument('--schedule', default=None, help='UTC Release date/time in ISO format: YYYY-MM-DDTHH:MM:SS (e.g. 2026-08-15T14:30:00)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
chosen_category = CategoryId[args.category]
|
||||
parsed_tags = [t.strip() for t in args.tags.split(',')] if args.tags else []
|
||||
|
||||
# ADDED: Parse schedule string into datetime object dynamically
|
||||
release_datetime = None
|
||||
if args.schedule:
|
||||
try:
|
||||
# Assumes format matches CLI help instruction text
|
||||
release_datetime = datetime.strptime(args.schedule, '%Y-%m-%dT%H:%M:%S').replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
print("Error: Schedule date must format strictly as YYYY-MM-DDTHH:MM:SS")
|
||||
return
|
||||
|
||||
upload_video(
|
||||
file_path=args.file,
|
||||
title=args.title,
|
||||
category=chosen_category,
|
||||
description=args.description,
|
||||
privacyStatus=args.privacy,
|
||||
tags=parsed_tags,
|
||||
release_time=release_datetime
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user