Compare commits

...
39 changed files with 1744 additions and 562 deletions

No files matched your search

+3
View File
@@ -6,3 +6,6 @@ save
__pycache__ __pycache__
twitch_secrets.json twitch_secrets.json
.vscode/settings.json .vscode/settings.json
download
output.log
*.mp4
@@ -1,16 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import requests import requests
import os import os
import linux import package.linux as linux
import time import time
from database import Database from package.database import Database
import uploader import package.youtube.uploader as uploader
from uploader import CategoryId from package.youtube.uploader import CategoryId
import youtube_hashtags import package.youtube.hashtags as hashtags
import transcribe_video import package.transcribe_video as transcribe_video
CHANNEL_NAME = 'teampgp' # Replace with the streamer's username CHANNEL_NAME = 'teampgp' # Replace with the streamer's username
DB = None DB = None
@@ -43,7 +43,7 @@ def top_hashtags(id: str):
print(f"Transcribe file not found {file_srt}") print(f"Transcribe file not found {file_srt}")
return [] return []
tags = youtube_hashtags.get_top_hashtags(file_srt) tags = hashtags.get_top_hashtags(file_srt)
return tags return tags
def download(): def download():
@@ -58,11 +58,11 @@ def download():
output = linux.run_command(f"TwitchDownloaderCLI videodownload --id {record_id} -o save/videos/{record_id}/{record_id}.mp4 --collision Overwrite") output = linux.run_command(f"TwitchDownloaderCLI videodownload --id {record_id} -o save/videos/{record_id}/{record_id}.mp4 --collision Overwrite")
output2 = linux.run_command(f"TwitchDownloaderCLI chatdownload --id {record_id} -o save/videos/{record_id}/{record_id}_chat.json -E --collision Overwrite") output2 = linux.run_command(f"TwitchDownloaderCLI chatdownload --id {record_id} -o save/videos/{record_id}/{record_id}_chat.json -E --collision Overwrite")
time.sleep(1) time.sleep(1)
if output["success"] is True: if output['success'] is True:
print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title} | was successful") print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title} | was successful")
DB.mark_as_downloaded(record_id) DB.mark_as_downloaded(record_id)
else: else:
print(f"ID: {record_id} | Download Process failed. {output["stdout"]}. Error: {output["stderr"]}") print(f"ID: {record_id} | Download Process failed. {output['stdout']}. Error: {output['stderr']}")
elif DB.table == "clips": elif DB.table == "clips":
for slug, record_date, title, game_name, clip_by, views, downloaded, uploaded_yt, uploaded_shorts_yt in undownloaded: for slug, record_date, title, game_name, clip_by, views, downloaded, uploaded_yt, uploaded_shorts_yt in undownloaded:
@@ -72,11 +72,11 @@ def download():
output = linux.run_command(f"TwitchDownloaderCLI clipdownload --id {slug} -o save/clips/{slug}/{slug}.mp4 --collision Overwrite") output = linux.run_command(f"TwitchDownloaderCLI clipdownload --id {slug} -o save/clips/{slug}/{slug}.mp4 --collision Overwrite")
time.sleep(1) time.sleep(1)
if output["success"] is True: if output['success'] is True:
print(f"Slug: {slug} | Was successfully downloaded.") print(f"Slug: {slug} | Was successfully downloaded.")
DB.mark_as_downloaded(slug) DB.mark_as_downloaded(slug)
else: else:
print(f"Slug: {slug} | Download Process failed. {output["stdout"]}. Error: {output["stderr"]}") print(f"Slug: {slug} | Download Process failed. {output['stdout']}. Error: {output['stderr']}")
print(f"Finished Downloading {DB.table}...") print(f"Finished Downloading {DB.table}...")
+8 -8
View File
@@ -3,10 +3,10 @@ import requests
import subprocess import subprocess
from datetime import datetime from datetime import datetime
from database import Database from package.database import Database
import uploader import package.youtube.uploader as uploader
from uploader import CategoryId from package.youtube.uploader import CategoryId
CHANNEL_NAME = 'teampgp' # Replace with the streamer's username CHANNEL_NAME = 'teampgp' # Replace with the streamer's username
DB = Database("clips") DB = Database("clips")
@@ -49,9 +49,9 @@ def top_hashtags(slug: str):
print(f"Transcribe file not found {file_srt}") print(f"Transcribe file not found {file_srt}")
return [] return []
import youtube_hashtags import package.youtube.hashtags as hashtags
return youtube_hashtags.get_top_hashtags(file_srt) return hashtags.get_top_hashtags(file_srt)
def download_clips(): def download_clips():
@@ -66,11 +66,11 @@ def download_clips():
# Uses standard clipdownload directive # Uses standard clipdownload directive
output = run_linux_command(f"TwitchDownloaderCLI clipdownload --id {slug} -o save/clips/{slug}/{slug}.mp4") output = run_linux_command(f"TwitchDownloaderCLI clipdownload --id {slug} -o save/clips/{slug}/{slug}.mp4")
if output["success"] is True: if output['success'] is True:
print(f"Slug: {slug} | Was successfully downloaded.") print(f"Slug: {slug} | Was successfully downloaded.")
DB.mark_as_downloaded(slug) DB.mark_as_downloaded(slug)
else: else:
print(f"Slug: {slug} | Download Process failed. {output["stdout"]}. Error: {output["stderr"]}") print(f"Slug: {slug} | Download Process failed. {output['stdout']}. Error: {output['stderr']}")
print("Finished Downloading Clips...") print("Finished Downloading Clips...")
@@ -94,7 +94,7 @@ def upload_clips():
tags.extend(top_hashtags({slug})) tags.extend(top_hashtags({slug}))
#output = uploader.upload_video(file_path, title, categoryId, description, privatcyStatus, tags) output = uploader.upload_video(file_path, title, categoryId, description, privatcyStatus, tags)
if output is True: if output is True:
print(f"Slug: {slug} | Was successfully uploaded.") print(f"Slug: {slug} | Was successfully uploaded.")
File renamed without changes.
+1 -1
View File
@@ -183,7 +183,7 @@ def get_clip_slugs(channel_name):
return clip_slugs return clip_slugs
except Exception as e: except Exception as e:
print(f"An unexpected error occurred: {e}") print(f"❌ An unexpected error occurred: {e}")
return [] return []
if __name__ == "__main__": if __name__ == "__main__":
View File
File renamed without changes.
File renamed without changes.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
from datetime import datetime, UTC
import package.database as database
DB = None
def has_no_timezone(dt_string: str) -> bool:
try:
# fromisoformat parses standard formats like YYYY-MM-DD HH:MM:SS
dt = datetime.fromisoformat(dt_string)
# Check if tzinfo is completely missing OR if its offset is undefined
return dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None
except ValueError:
print(f"Error: '{dt_string}' is not a valid date-time string.")
return False
def fix_time():
chats = DB.get_vods()
#chats = DB.get_clips()
for chat in chats:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = chat
if "T" in created_at:
date_string = created_at.replace("T", " ")
DB.update_datetime(id, date_string)
if has_no_timezone(created_at):
dt_with_utc = datetime.fromisoformat(created_at).replace(tzinfo=UTC)
date_string = dt_with_utc.isoformat().strftime('%Y-%m-%d %H:%M:%S %z')
DB.update_datetime(id, date_string)
print("====================================================")
print(f"✅ Processing: Times Fix.")
print("====================================================")
def main():
global DB
DB = database.Database()
fix_time()
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
from pathlib import Path
import package.database as database
DB = None
def fix_download():
chats = DB.get_undownloaded()
#chats = DB.get_clips()
for chat in chats:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = chat
if clip_is:
file_name = Path(f"download/clips/{id}/{id}.mp4")
else:
file_name = Path(f"download/videos/{id}/{id}.mp4")
if file_name.exists():
DB.mark_as_downloaded(id)
print("====================================================")
print(f"✅ Processing: download Fix.")
print("====================================================")
def main():
global DB
DB = database.Database()
fix_download()
if __name__ == "__main__":
main()
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
import os
import json
import asyncio
from pathlib import Path
import package.database as database
import package.twitch.video_info as video_info
DB = None
def format_to_hms(total_seconds):
# Calculate hours, minutes, and seconds
hours, remainder = divmod(int(total_seconds), 3600)
minutes, seconds = divmod(remainder, 60)
# Return formatted string: e.g., 4h27m12s
return f"{hours}h{minutes}m{seconds}s"
def read_json(file_path: str):
try:
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
# Extracting streamer info
streamer_name = data.get("streamer", {}).get("name")
# Extracting video core details
video_data = data.get("video", {})
video_title = video_data.get("title")
video_id = video_data.get("id")
video_created_at = video_data.get("created_at")
video_length_seconds = video_data.get("length")
video_views = video_data.get("viewCount")
video_game = video_data.get("game")
# Printing variables to verify
print(f"Streamer: {streamer_name}")
print(f"Title: {video_title}")
print(f"Video ID: {video_id}")
print(f"Created At: {video_created_at}")
print(f"Duration: {video_length_seconds} seconds")
print(f"Views: {video_views}")
print(f"Game: {video_game}")
video_length = format_to_hms(video_length_seconds)
game_id = asyncio.run(video_info.get_game_id_by_name(video_game))
DB.insert_video_record(
video_id,
video_title,
video_created_at,
int(video_views),
video_length,
"",
"",
game_id,
video_game,
"",
"",
False
)
id = video_id
source = Path(f"download/saveold/{id}")
destination = Path(f"download/videos/{id}")
if source.exists() and source.is_dir():
source.rename(destination)
print("====================================================")
print(f"🚀 VOD to moved: {video_title}")
print("====================================================")
except FileNotFoundError:
print(f"Error: The file at {file_path} was not found.")
except json.JSONDecodeError:
print(
"Error: Failed to parse JSON. Make sure trailing cut-off text is removed."
)
def main():
global DB
DB = database.Database()
# Set the path of the directory you want to walk through
# Use "." for the current folder, or a full path like "C:/Users/Name/Documents"
root_directory = "./download/saveold"
# os.walk yields a 3-item tuple for every single folder it finds
for current_folder, subfolders, files in os.walk(root_directory):
print(f"\n📂 Current Folder: {current_folder}")
# List all subfolders inside the current folder
if subfolders:
print(f" 📁 Subfolders found: {', '.join(subfolders)}")
# List all files inside the current folder
for file_name in files:
# Construct the full absolute or relative path to the file
full_file_path = os.path.join(current_folder, file_name)
print(f" 📄 File: {file_name} ({full_file_path})")
if file_name.endswith(".json"):
read_json(full_file_path)
if __name__ == "__main__":
main()
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
import sqlite3
# Connect to your database file
db_name = "database.db" # Change to your actual file name
conn = sqlite3.connect(db_name)
cursor = conn.cursor()
try:
# 1. Create a temporary table with the new text-based game_id
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS twitch_videos_temp (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
view_count INTEGER NOT NULL,
duration TEXT NOT NULL,
url TEXT NOT NULL,
thumbnail_url TEXT NOT NULL,
game_id TEXT NOT NULL,
game_name TEXT NOT NULL,
stream_id TEXT NOT NULL,
creator_name TEXT NOT NULL,
clip_is BOOLEAN NOT NULL DEFAULT 0,
downloaded BOOLEAN NOT NULL DEFAULT 0,
uploaded_yt BOOLEAN NOT NULL DEFAULT 0,
uploaded_yt_chats BOOLEAN NOT NULL DEFAULT 0,
uploaded_yt_shorts BOOLEAN NOT NULL DEFAULT 0
)
"""
)
# 2. Copy and convert data to the temporary table
cursor.execute(
"""
INSERT INTO twitch_videos_temp (
id, title, created_at, view_count, duration, url, thumbnail_url,
game_id, game_name, stream_id, creator_name, clip_is, downloaded,
uploaded_yt, uploaded_yt_chats, uploaded_yt_shorts
)
SELECT
id, title, created_at, view_count, duration, url, thumbnail_url,
CAST(game_id AS TEXT), game_name, stream_id, creator_name, clip_is, downloaded,
uploaded_yt, uploaded_yt_chats, uploaded_yt_shorts
FROM twitch_videos
"""
)
# 3. Drop the old table configuration
cursor.execute("DROP TABLE twitch_videos")
# 4. Rename the temporary table to your exact original table name
cursor.execute("ALTER TABLE twitch_videos_temp RENAME TO twitch_videos")
# Commit changes if everything succeeded
conn.commit()
print("Migration successful! 'twitch_videos' table updated.")
except sqlite3.Error as e:
# Roll back changes if an error occurs
conn.rollback()
print(f"An error occurred: {e}")
finally:
# Close database connection
conn.close()
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import package.database as database
from pathlib import Path
DB = None
def remove_vods():
chats = DB.get_vods()
#chats = DB.get_clips()
for chat in chats:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = chat
#shutil.rmtree(f"", ignore_errors=True)
if clip_is:
source = Path(f"../saveoldold/clips/{id}")
destination = Path(f"../savedup/clips/{id}")
else:
source = Path(f"../saveoldold/{id}")
destination = Path(f"../savedup/{id}")
if source.exists() and source.is_dir():
source.rename(destination)
print("====================================================")
print(f"🚀 VOD to remove: {title}")
print("====================================================")
print("====================================================")
print(f"✅ Processing: VOD removal Done.")
print("====================================================")
def main():
global DB
DB = database.Database()
remove_vods()
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import package.database as database
DB = None
def redownload_clips():
clips = DB.get_clips()
for clip in clips:
# Unpack variables clearly
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = clip
DB.unmark_as_download(id)
if __name__ == "__main__":
DB = database.Database()
redownload_clips()
+72
View File
@@ -0,0 +1,72 @@
import os
import package.database as database
DB = None
def remove_files():
datas = DB.get_download()
top_txt = "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp"
for data in datas:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = data
if clip_is == 0:
video_path = f"download/videos/{id}/{id}.mp4"
else:
video_path = f"download/clips/{id}/{id}.mp4"
temp_chat = video_path.replace(".mp4", "_temp_chat.mp4")
temp_with_chat = video_path.replace(".mp4", "_temp_with_chat.mp4")
video_chat = video_path.replace(".mp4", "_with_chat.mp4")
video_chatSS = video_path.replace(".mp4", "_SS_with_chat.mp4")
video_chat_over = video_path.replace(".mp4", "_over_with_chat.mp4")
mask_path = video_path.replace(".mp4", "_temp_chat_mask.mp4")
shorts_gaussian_9_16 = ""#video_path.replace(".mp4", "_gaussian_9_16.mp4")
shorts_9_16 = ""#video_path.replace(".mp4", "_9_16.mp4")
srt_file = ""#video_path.replace(".mp4", ".srt")
files = [temp_chat, temp_with_chat, video_chatSS, video_chat_over, mask_path, shorts_gaussian_9_16, shorts_9_16, srt_file, video_chat]
for file in files:
if os.path.exists(file):
if file == video_path:
print("❌ Error: trying to delete import file.")
return
else:
os.remove(file)
def redownload():
DB.unmark_as_download("2840110867")
def main():
global DB
DB = database.Database()
remove_files()
redownload()
if __name__ == "__main__":
main()
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env python3
import subprocess
def run_command(command: str):
"""Executes a Linux command, waits for completion, and returns output."""
try:
# shell=True allows running full command strings with pipes/wildcards
# text=True returns strings instead of bytes
result = subprocess.run(
command, shell=True, check=True, capture_output=True, text=True
)
return {"success": True, "stdout": result.stdout, "stderr": result.stderr}
except subprocess.CalledProcessError as e:
# Handles errors if the Linux command returns a non-zero exit code
return {"success": False, "stdout": e.stdout, "stderr": e.stderr}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import asyncio
import package.twitch.video_info as video_info
import package.twitch.download_videos as download_videos
import package.twitch.download_thumbnails as download_thumbnails
import package.build_videos as build_videos
if __name__ == "__main__":
# Get Twitch Video Info
asyncio.run(video_info())
# Download Twitch Videos
download_videos()
download_thumbnails()
#Build
build_videos()
print("====================================================")
print(f"✅ Done.")
print("====================================================")
+35
View File
@@ -0,0 +1,35 @@
# package/__init__.py
import os
# 1. Package Metadata
__version__ = "1.0.0"
__author__ = "Richard Allen"
# 2. Initialization Logic
print("Initializing package...")
# Ensure a required directory exists when the package loads
CACHE_DIR = os.path.join(os.path.expanduser("~"), ".package_cache")
os.makedirs(CACHE_DIR, exist_ok=True)
# 3. Expose Database Tools (Added)
# Replace 'DatabaseClient' and 'connect_to_db' with your actual class/function names
from .build_videos import build_videos
from .config import CHANNEL_NAME, DB
from .database import Database
from .transcribe_video import transcribe_to_srt
from .utils import run_command
# 4. Expose Subpackages
from . import twitch
# (Optional) Defines what is exposed during 'from package import *'
__all__ = [
"build_videos",
"CHANNEL_NAME",
"DB",
"Database",
"transcribe_to_srt",
"utils",
"twitch",
]
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
import package.youtube.shorts as yt_shorts
import package.transcribe_video as transcribe_video
import package.twitch.chat_vod as chat_vod
from package.config import DB as DB
def build_chat_video():
chats = DB.get_unuploaded_chats()
for chat in chats:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = chat
print("====================================================")
print(f"🚀 Chat Video: {title}")
print("====================================================")
chat_vod.combine_twitch_vod_and_chat(f"download/videos/{id}/{id}.mp4", "side-by-side")
print("====================================================")
print(f"✅ Processing: Chat Video Done.")
print("====================================================")
def build_transcribe():
unuploadeds = DB.get_unuploaded()
for unuploaded in unuploadeds:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = unuploaded
if clip_is:
target_dir = f"download/clips"
else:
target_dir = f"download/videos"
print("====================================================")
print(f"🚀 Transcribe: {title}")
print("====================================================")
transcribe_video.transcribe_to_srt(f"{target_dir}/{id}/{id}.mp4")
print("====================================================")
print(f"✅ Processing: Transcribing Done.")
print("====================================================")
def build_shorts():
shorts = DB.get_unuploaded_shorts()
top_txt = "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp"
for short in shorts:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = short
target_dir = f"download/clips/{id}/{id}.mp4"
print("====================================================")
print(f"🚀 Processing: Clip to Youtube Short {title}")
print("====================================================")
yt_shorts.fit_to_9_16_letterbox(target_dir, top_txt, f"Clipped By: {creator_name}.")
print("====================================================")
print(f"✅ Processing: Clip to Youtube Short Done.")
print("====================================================")
def build_videos():
build_shorts()
build_transcribe()
build_chat_video()
if __name__ == "__main__":
build_videos()
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env python3
import package.database as database
CHANNEL_NAME = "teampgp"
DB = database.Database()
+52 -15
View File
@@ -5,19 +5,18 @@ from pathlib import Path
from typing import Any from typing import Any
class Database: class Database:
def __init__(self): def __init__(self, db_path: str = "database.db"):
self.columns = "id, title, created_at, view_count, duration, url, thumbnail_url, game_id, game_name, stream_id, creator_name, clip_is, downloaded, uploaded_yt, uploaded_yt_chats, uploaded_yt_shorts" self.columns = "id, title, created_at, view_count, duration, url, thumbnail_url, game_id, game_name, stream_id, creator_name, clip_is, downloaded, uploaded_yt, uploaded_yt_chats, uploaded_yt_shorts"
# IMPORTANT Must check if database.db exists before connecting to it. file_exists = Path(db_path).is_file()
file_exists = Path("database.db").is_file()
self.conn = sqlite3.connect("database.db") self.conn = sqlite3.connect(db_path)
self.cursor = self.conn.cursor() self.cursor = self.conn.cursor()
if not file_exists: if not file_exists:
self.create_database() self.create_database()
def __del__(self): def __exit__(self):
# Destructors are unpredictable in Python; explicitly close when done instead # Destructors are unpredictable in Python; explicitly close when done instead
try: try:
self.close_database() self.close_database()
@@ -35,7 +34,7 @@ class Database:
duration TEXT NOT NULL, duration TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
thumbnail_url TEXT NOT NULL, thumbnail_url TEXT NOT NULL,
game_id INTEGER NOT NULL, game_id TEXT NOT NULL,
game_name TEXT NOT NULL, game_name TEXT NOT NULL,
stream_id TEXT NOT NULL, stream_id TEXT NOT NULL,
creator_name TEXT NOT NULL, creator_name TEXT NOT NULL,
@@ -55,10 +54,10 @@ class Database:
self.conn.commit() self.conn.commit()
self.conn.close() self.conn.close()
def __mark_as(self, record_id: str, set_sql: str): def __mark_as(self, record_id: str, set_row: str, mark: str = "1" ):
self.cursor.execute( self.cursor.execute(
f"UPDATE twitch_videos SET {set_sql} = 1 WHERE id = ?", f"UPDATE twitch_videos SET {set_row} = ? WHERE id = ?",
(record_id,)) (mark, record_id))
self.conn.commit() self.conn.commit()
def mark_as_uploaded_shorts(self, record_id: str): def mark_as_uploaded_shorts(self, record_id: str):
@@ -77,18 +76,29 @@ class Database:
"""Flags a specific row record to downloaded (1).""" """Flags a specific row record to downloaded (1)."""
self.__mark_as(record_id, "downloaded") self.__mark_as(record_id, "downloaded")
def __get_unuploaded(self, set_sql: str) -> list[Any]: def unmark_as_download(self, record_id: str):
"""Flags a specific row record to downloaded (0)."""
self.__mark_as(record_id, "downloaded", "0")
def update_datetime(self, record_id: str, created_at: str):
"""Updates the Datetime for timezone fix"""
self.cursor.execute(
f"UPDATE twitch_videos SET created_at = ? WHERE id = ?",
(created_at, record_id))
self.conn.commit()
def __get_unuploaded(self, set_row: str, also: str = "") -> list[Any]:
"""Retrieve all rows that were download but not uploaded""" """Retrieve all rows that were download but not uploaded"""
self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE downloaded = 1 AND {set_sql} = 0") self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE downloaded = 1 AND {set_row} = 0 {also}")
return self.cursor.fetchall() return self.cursor.fetchall()
def get_unuploaded_shorts(self) -> list[Any]: def get_unuploaded_shorts(self) -> list[Any]:
"""Retrieve all rows that were download but not uploaded_yt_shorts""" """Retrieve all rows that were download but not uploaded_yt_shorts"""
return self.__get_unuploaded("uploaded_yt_shorts") return self.__get_unuploaded("uploaded_yt_shorts", "AND clip_is = 1")
def get_unuploaded_chats(self) -> list[Any]: def get_unuploaded_chats(self) -> list[Any]:
"""Retrieve all rows that were download but not uploaded_yt_chats""" """Retrieve all rows that were download but not uploaded_yt_chats"""
return self.__get_unuploaded("uploaded_yt_chats") return self.__get_unuploaded("uploaded_yt_chats", "AND clip_is = 0")
def get_unuploaded(self) -> list[Any]: def get_unuploaded(self) -> list[Any]:
"""Retrieve all rows that were download but not uploaded_yt""" """Retrieve all rows that were download but not uploaded_yt"""
@@ -99,6 +109,20 @@ class Database:
self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE downloaded = 0") self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE downloaded = 0")
return self.cursor.fetchall() return self.cursor.fetchall()
def get_download(self) -> list[Any]:
"""Retrieves all rows that were downloaded"""
self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE downloaded = 1")
return self.cursor.fetchall()
def get_clips(self) -> list[Any]:
"""Retrieves all rows that are clip_is"""
self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE clip_is = 1")
return self.cursor.fetchall()
def get_vods(self) -> list[Any]:
self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE clip_is = 0")
return self.cursor.fetchall()
def insert_video_record( def insert_video_record(
self, id: str, title: str, created_at: str, view_count: int, duration: str, self, id: str, title: str, created_at: str, view_count: int, duration: str,
url: str, thumbnail_url: str, game_id: int, game_name: str, stream_id: str, url: str, thumbnail_url: str, game_id: int, game_name: str, stream_id: str,
@@ -112,10 +136,23 @@ class Database:
# Explicitly defining columns removes the security risk and column-count bug # Explicitly defining columns removes the security risk and column-count bug
query = """ query = """
INSERT OR IGNORE INTO twitch_videos ( INSERT INTO twitch_videos (
id, title, created_at, view_count, duration, url, thumbnail_url, id, title, created_at, view_count, duration, url, thumbnail_url,
game_id, game_name, stream_id, creator_name, clip_is game_id, game_name, stream_id, creator_name, clip_is
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
created_at = excluded.created_at,
view_count = excluded.view_count,
duration = excluded.duration,
url = excluded.url,
thumbnail_url = excluded.thumbnail_url,
game_id = excluded.game_id,
game_name = excluded.game_name,
stream_id = excluded.stream_id,
creator_name = excluded.creator_name,
clip_is = excluded.clip_is
WHERE excluded.duration != twitch_videos.duration
""" """
values = ( values = (
@@ -128,5 +165,5 @@ class Database:
self.conn.commit() self.conn.commit()
except Exception as e: except Exception as e:
# Prevent silent failures if the database connection drops # Prevent silent failures if the database connection drops
print(f"Database insertion failed: {e}") print(f"❌ Database insertion failed: {e}")
self.conn.rollback() self.conn.rollback()
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
import os
import shutil
import subprocess
from pathlib import Path
from faster_whisper import WhisperModel
from faster_whisper.utils import format_timestamp
# Prevent OpenMP thread conflicts from crashing the script
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
# 5 minutes per chunk (300 seconds) keeps RAM usage low and stable
CHUNK_DURATION_SEC = 300
print("🔧 Initializing C++ Engine...")
model = WhisperModel(
"base",
device="cpu",
compute_type="int8",
cpu_threads=0, # Let CTranslate2 auto-detect safe core counts
num_workers=1
)
print("✅ C++ Model loaded successfully.")
def extract_audio_and_chunk(video_path: str, output_dir: Path) -> list:
"""Extracts and splits audio into 5-minute chunks using a single FFmpeg pass."""
print("🚀 Extracting and chunking audio with FFmpeg...")
output_dir.mkdir(parents=True, exist_ok=True)
# Segment format output: chunk_000.wav, chunk_001.wav, etc.
chunk_pattern = str(output_dir / "chunk_%03d.wav")
command = [
"ffmpeg", "-y", "-i", video_path,
"-vn", "-ac", "1", "-ar", "16000",
"-acodec", "pcm_s16le", "-sn", "-map_chapters", "-1",
"-f", "segment", "-segment_time", str(CHUNK_DURATION_SEC),
chunk_pattern
]
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
print(f"❌ FFmpeg Error Output:\n{result.stderr}")
raise RuntimeError("FFmpeg extraction and chunking failed.")
# Return sorted list of generated chunk files
return sorted(list(output_dir.glob("chunk_*.wav")))
def transcribe_to_srt(video_path: str, force: bool = False):
video_path_obj = Path(video_path)
srt_path = video_path_obj.with_suffix(".srt")
temp_dir = video_path_obj.parent / f"temp_chunks_{video_path_obj.stem}"
if srt_path.exists():
if not force:
print(f"❌ Error: Transcription file already exists: {srt_path}")
return
else:
srt_path.unlink()
try:
# Step 1: Split audio into bite-sized pieces
audio_chunks = extract_audio_and_chunk(str(video_path_obj), temp_dir)
if not audio_chunks:
print("❌ Error: No audio chunks were generated.")
return
print(f"📦 Successfully split audio into {len(audio_chunks)} chunks.")
print("🎙️ Starting safe chunk-by-chunk transcription...")
global_segment_index = 1
with open(srt_path, "w", encoding="utf-8") as srt_file:
for chunk_idx, chunk_path in enumerate(audio_chunks):
# Calculate the time offset for the current chunk
time_offset = chunk_idx * CHUNK_DURATION_SEC
print(f"\n⏳ Processing chunk {chunk_idx + 1}/{len(audio_chunks)} ({chunk_path.name})...")
segments_generator, info = model.transcribe(
str(chunk_path),
beam_size=1,
vad_filter=True,
temperature=0.0
)
# Consume chunk generator and shift timestamps instantly
for segment in segments_generator:
# Shift timestamps relative to the original video timeline
actual_start = segment.start + time_offset
actual_end = segment.end + time_offset
start_str = format_timestamp(actual_start, always_include_hours=True)
end_str = format_timestamp(actual_end, always_include_hours=True)
srt_file.write(f"{global_segment_index}\n{start_str} --> {end_str}\n{segment.text.strip()}\n\n")
global_segment_index += 1
# Free up space as we go by deleting the processed chunk
chunk_path.unlink()
print(f"\n✅ All chunks combined! SRT subtitle file saved in: {srt_path}")
except Exception as e:
print(f"\n❌ Execution Error: {e}")
finally:
# Clean up the temporary folder entirely
if temp_dir.exists():
shutil.rmtree(temp_dir)
if __name__ == "__main__":
target_video = "download/videos/2813112936/2813112936.mp4"
if not os.path.exists(target_video):
print(f"❌ System Error: Target video file does not exist at path: {target_video}")
else:
transcribe_to_srt(target_video, force=True)
+15
View File
@@ -0,0 +1,15 @@
# package/twitch/__init__.py
# Expose components from individual modules using relative imports
from .chat_vod import combine_twitch_vod_and_chat
from .download_thumbnails import download_thumbnails
from .download_videos import download_videos
from .video_info import video_info
# Explicitly define what is exposed when importing the subpackage
__all__ = [
"combine_twitch_vod_and_chat",
"download_thumbnails",
"download_videos",
"video_info",
]
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
import os
import cv2
import package.utils as utils
def get_video_height(video_path: str) -> int:
"""
Gets the videos height
"""
# Open the video file
video = cv2.VideoCapture(video_path)
# Get the height property
height = int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
# Always release the video object
video.release()
return height
def combine_twitch_vod_and_chat(vod_path: str, layout: str = "side-by-side", force: bool = False) -> bool:
"""
Renders Twitch chat JSON to video and combines it with the source VOD.
Provides real-time terminal feedback for all processing steps.
"""
threads = 8
video_height = 1080
video_path = vod_path
video_chat = ""
chat_path = video_path.replace(".mp4", "_chat.json")
temp_with_chat = video_path.replace(".mp4", "_temp_with_chat.mp4")
temp_chat = video_path.replace(".mp4", "_temp_chat.mp4")
video_chat_SS = video_path.replace(".mp4", "_SS_with_chat.mp4")
video_chat_over = video_path.replace(".mp4", "_over_with_chat.mp4")
mask_path = video_path.replace(".mp4", "_temp_chat_mask.mp4")
# Step 1: Pre-flight checks
if not os.path.exists(video_path):
print(f"❌ Error: Source video not found: {video_path}")
return False
if not os.path.exists(chat_path):
print(f"❌ Error: Chat file not found: {chat_path}")
return False
if os.path.exists(temp_with_chat):
os.remove(temp_with_chat)
if layout == "side-by-side":
video_chat = video_chat_SS
elif layout == "overlay":
video_chat = video_chat_over
else:
return False
if os.path.exists(video_chat):
if not force:
print(f"❌ Error: Chat video already exists: {video_chat}")
return False
else:
os.remove(video_chat)
if os.path.exists(temp_chat):
os.remove(temp_chat)
if os.path.exists(mask_path):
os.remove(mask_path)
# Step 2: Render Chat to Video
print("====================================================")
print("🚀 STEP 1: Rendering Chat JSON to Video Layer")
print("====================================================")
video_height = get_video_height(vod_path)
chat_cmd = (
f"TwitchDownloaderCLI chatrender "
f"-i {chat_path} "
f"-w 400 -h {video_height} "
f"--collision Overwrite "
f"--temp-path download/temp "
f"--font-size 20 "
f"--background-color #00000000 "
f"-o {temp_chat}"
)
if layout == "overlay":
chat_cmd = (f"{chat_cmd} --generate-mask ")
if not os.path.exists(temp_chat):
chat_success = utils.run_command(chat_cmd, look_for=["[STATUS]"])
if chat_success:
print("✅ Success: TwitchDownloaderCLI render chat video.")
else:
print("❌ Error: TwitchDownloaderCLI failed to render chat video.")
if os.path.exists(temp_chat):
os.remove(temp_chat)
if os.path.exists(mask_path):
os.remove(mask_path)
return False
# Step 3: Combine Video and Chat using FFmpeg
print("====================================================")
print(f"🚀 STEP 2: Merging VOD and Chat Layout ({layout})")
print("====================================================")
# -preset superfast speeds up the 3+ hour encoding process significantly
# -map 0:a? safely includes audio if it exists, without breaking on silent VODs
if layout == "side-by-side":
ffmpeg_cmd = (
f"ffmpeg -y -i {video_path} -i {temp_chat} "
f"-filter_complex '[1:v]scale=-1:ih[scaled_chat];[0:v][scaled_chat]hstack=inputs=2[v]' "
f"-map '[v]' -map 0:a? -c:v libx264 -crf 18 -preset slow -c:a copy "
f"-threads {threads} {temp_with_chat}"
)
elif layout == "overlay":
ffmpeg_cmd = (
f"ffmpeg -y -i {video_path} -i {temp_chat} -i {mask_path} "
f"-filter_complex '[1:v][2:v]alphamerge[masked_chat];[0:v][masked_chat]overlay=x=0:y=10[v]' "
f"-map '[v]' -map 0:a? -c:v libx264 -crf 18 -preset slow -c:a copy "
f"-threads {threads} {temp_with_chat}"
)
else:
raise ValueError("Invalid layout choice. Choose 'side-by-side' or 'overlay'.")
ffmpeg_success = utils.run_command(ffmpeg_cmd, progress_prefix="FFmpeg Merge")
# Step 4: Final verification and cleanup
if ffmpeg_success and os.path.exists(temp_with_chat):
os.rename(temp_with_chat, video_chat)
print("====================================================")
print(f"🎉 SUCCESS: Video processing complete!")
print(f"📁 Output Saved: {video_chat}")
print("====================================================")
# Clean up the massive temporary chat video to save storage space
if os.path.exists(temp_chat):
print("🧹 Cleaning up temporary chat render video...")
os.remove(temp_chat)
if os.path.exists(mask_path):
os.remove(mask_path)
if os.path.exists(temp_with_chat):
os.remove(temp_with_chat)
return True
else:
print("❌ Error: FFmpeg failed to merge the video streams.")
if os.path.exists(video_chat):
os.remove(video_chat)
if os.path.exists(temp_with_chat):
os.remove(temp_with_chat)
return False
if __name__ == "__main__":
combine_twitch_vod_and_chat("download/videos/2813112936/2813112936.mp4", "overlay", True)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import json
from twitchAPI.twitch import Twitch
from twitchAPI.helper import first
from package.config import CHANNEL_NAME as CHANNEL_NAME
# 1. Fill in your credentials from the Twitch Developer Console
SECRETS = None
TWITCH = None
USER = None
async def get_twitch():
global SECRETS, TWITCH, USER
if SECRETS is None:
with open('twitch_secrets.json', 'r') as f:
SECRETS = json.load(f)
if TWITCH is None:
TWITCH = await Twitch(SECRETS['client_id'], SECRETS['client_secret'])
if USER is None:
USER = await first(TWITCH.get_users(logins=[CHANNEL_NAME]))
if not USER:
print("User not found.")
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
import os
import asyncio
import requests
from twitchAPI.helper import first
import package.config as config
import package.database as database
async def download_live_thumbnail(twitch_client, streamer_username: str, w: int = 1920, h: int = 1080):
"""Fetches and saves the live stream thumbnail for an active broadcast."""
print(f"🔎 Checking live status for: {streamer_username}...")
# Query the live streams endpoint
stream_generator = twitch_client.get_streams(user_logins=[streamer_username])
stream_data = await first(stream_generator)
if not stream_data:
print(f"❌ User '{streamer_username}' is offline. Live thumbnails require an active stream.")
return
# Twitch API live streams use the {width} and {height} format
raw_url = stream_data.thumbnail_url
clean_url = raw_url.replace('{width}', str(w)).replace('{height}', str(h))
filename = f"live_{streamer_username}_{w}x{h}.jpg"
save_image(clean_url, filename)
async def download_vod_thumbnail(twitch_client, vod_id: str, w: int = 1920, h: int = 1080):
"""Fetches and saves a thumbnail from a past broadcast VOD ID."""
print(f"🔎 Searching for VOD ID: {vod_id}...")
# Query the videos endpoint
video_generator = twitch_client.get_videos(vod_id)
video_data = await first(video_generator)
filename = f"download/videos/{vod_id}/{vod_id}_{w}x{h}.jpg"
if os.path.exists(filename):
return
if not video_data:
print(f"❌ VOD ID {vod_id} could not be found.")
return
# Twitch VOD endpoints typically format string tokens as %{width} and %{height}
raw_url = video_data.thumbnail_url
if not raw_url:
print("❌ This VOD does not have an available thumbnail.")
return
clean_url = raw_url.replace('%{width}', str(w)).replace('%{height}', str(h))
#filename = f"vod_{vod_id}_{w}x{h}.jpg"
save_image(clean_url, filename)
async def download_clip_thumbnail(clip_id: str, url: str):
print(f"🔎 Searching for Clip ID: {clip_id}...")
filename = f"download/clips/{clip_id}/{clip_id}.jpg"
if os.path.exists(filename):
return
save_image(url, filename, False)
def save_image(url: str, filename: str, stream: bool = True):
"""Helper function to stream image bytes directly to a file."""
try:
response = requests.get(url, stream)
if response.status_code == 200:
with open(filename, 'wb') as file:
if stream is True:
for chunk in response.iter_content(1024):
file.write(chunk)
else:
file.write(response.content)
print(f"✅ Success! Saved as: {filename}")
else:
print(f"❌ Download failed. HTTP Status: {response.status_code}")
except Exception as e:
print(f"❌ An error occurred during file writing: {e}")
async def download_thumbnails():
# Initialize connection & automatically authorize the App token
await config.get_twitch()
twitch = config.TWITCH # Twitch(APP_ID, APP_SECRET)
# --- OPTION A: Download Live Thumbnail ---
# Target user must be streaming live right now
target_streamer = config.CHANNEL_NAME
#await download_live_thumbnail(twitch, target_streamer, 1920, 1080)
# --- OPTION B: Download Past Broadcast VOD Thumbnail ---
# Extract the ID sequence from your target video link
# target_vod = "2145678901"
db = database.Database()
vods = db.get_vods()
for vod in vods:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = vod
await download_vod_thumbnail(twitch, id, 1920, 1080)
clips = db.get_clips()
for clip in clips:
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = clip
await download_clip_thumbnail(id, thumbnail_url)
if __name__ == '__main__':
asyncio.run(download_thumbnails())
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
import csv
import subprocess
from datetime import datetime, timedelta, timezone
import package.utils as utils
from package.config import DB as DB
def write_csv(data, file_name):
# Open file with newline='' to prevent extra blank rows across platforms
with open(file_name, "w", newline="", encoding="utf-8") as file:
writer = csv.writer(file)
# Write all rows at once
writer.writerows(data)
def download_videos():
"""Find all undownloaded videos and clips, and download them safely."""
undownloaded = DB.get_undownloaded()
print("Starting downloads...")
for row in undownloaded:
# Unpack variables clearly
(
id,
title,
created_at,
view_count,
duration,
url,
thumbnail_url,
game_id,
game_name,
stream_id,
creator_name,
clip_is,
downloaded,
uploaded_yt,
uploaded_yt_chats,
uploaded_yt_shorts,
) = row
# 1. Define your data's timestamp (Example: April 10, 2026, at 10:00 AM)
#datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ")
data_timestamp = datetime.fromisoformat(created_at)
# 2. Get the exact current date and time
current_time = datetime.now(timezone.utc)
# 3. Calculate the difference between the two times
time_difference = current_time - data_timestamp
# 4. Check if the difference is greater than 24 hours
if time_difference > timedelta(hours=24):
print("The data is more than 24 hours old.")
else:
#lets wait 24 hours befor downloading
print("The data is less than 24 hours old.")
continue
data = [list(row)]
print(
f"ID: {id} | Date: {created_at} | Game: {game_name} | Title: {title}"
)
# 1. Define paths and isolate base directory
if clip_is:
target_dir = f"download/clips/{id}"
cmd = f"TwitchDownloaderCLI clipdownload --id {id} -o {target_dir}/{id}.mp4 --collision Overwrite --temp-path download/temp"
else:
target_dir = f"download/videos/{id}"
# FIXED: Removed the duplicated command string combined with '&&'
cmd = f"TwitchDownloaderCLI videodownload --id {id} -o {target_dir}/{id}.mp4 --collision Overwrite --threads 2 --temp-path download/temp"
csv_file = f"{target_dir}/{id}.csv"
# 3. FIXED: Use the live streaming function to prevent Out-Of-Memory crashes
output = utils.run_command(cmd, look_for=["[STATUS]"])
output2 = utils.run_command(f"TwitchDownloaderCLI chatdownload --id {id} -o {target_dir}/{id}_chat.json -E --collision Overwrite --temp-path download/temp", look_for=["[STATUS]"])
if output:
# Only write CSV and update database if download actually completed
write_csv(data, csv_file)
print(f"✅ Success: TwitchDownloaderCLI video. {target_dir}")
DB.mark_as_downloaded(id)
else:
print(f"ID: {id} | Download Process failed.")
#print(f"❌ Reason/Error: {output.get('error', 'Unknown Error')}")
#if output.get("stderr"):
# print(f"Details: {output['stderr']}")
# Clear temporary chunk clutter immediately if a VOD crashes out
if not clip_is:
print("Flushing temporary crash chunks...")
subprocess.run("rm -rf download/temp/*", shell=True)
print("Finished Downloading Pipeline.")
if __name__ == "__main__":
download_videos()
@@ -1,30 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import json
import httpx # Switched from requests to prevent async loop freezing import httpx # Switched from requests to prevent async loop freezing
import database
from twitchAPI.twitch import Twitch
from twitchAPI.helper import first from twitchAPI.helper import first
from twitchAPI.type import VideoType from twitchAPI.type import VideoType
SECRETS = None import package.twitch.config as config
TWITCH = None from package.config import DB as DB
USER = None
GAME_CACHE = {} # Local cache dictionary to store game_id -> game_name mapping GAME_CACHE = {} # Local cache dictionary to store game_id -> game_name mapping
CHANNEL_NAME = "teampgp" async def get_game_id_by_name(game_name: str) -> str:
if config.SECRETS is None:
await config.get_twitch()
# Query the 'get_games' endpoint using the names parameter
# The API expects a list, even for a single game name
async for game in config.TWITCH.get_games(names=[game_name]):
print(f"Game Name: {game.name}")
print(f"Game ID: {game.id}")
print(f"IGDB ID: {game.igdb_id}")
return game.id
async def get_twitch(): print(f"No game found with the exact name: '{game_name}'")
global SECRETS, TWITCH, USER return None
if SECRETS is None:
with open('twitch_secrets.json', 'r') as f:
SECRETS = json.load(f)
if TWITCH is None:
TWITCH = await Twitch(SECRETS['client_id'], SECRETS['client_secret'])
if USER is None:
USER = await first(TWITCH.get_users(logins=[CHANNEL_NAME]))
if not USER:
print("User not found.")
async def get_game_name_by_id(game_id: str) -> str: async def get_game_name_by_id(game_id: str) -> str:
"""Helper function to fetch game names and cache them locally.""" """Helper function to fetch game names and cache them locally."""
@@ -33,7 +31,7 @@ async def get_game_name_by_id(game_id: str) -> str:
if game_id in GAME_CACHE: if game_id in GAME_CACHE:
return GAME_CACHE[game_id] return GAME_CACHE[game_id]
try: try:
game_generator = TWITCH.get_games(game_ids=[game_id]) game_generator = config.TWITCH.get_games(game_ids=[game_id])
game = await first(game_generator) game = await first(game_generator)
if game: if game:
GAME_CACHE[game_id] = game.name GAME_CACHE[game_id] = game.name
@@ -42,7 +40,7 @@ async def get_game_name_by_id(game_id: str) -> str:
pass pass
return "Unknown Game" return "Unknown Game"
async def get_vod_game_name(vod_id: str): async def get_vod_game_name(vod_id: str) -> tuple:
"""Asynchronously query Twitch GQL endpoint for VOD game metadata.""" """Asynchronously query Twitch GQL endpoint for VOD game metadata."""
game_id = "0" game_id = "0"
game_name = "Unknown Game" game_name = "Unknown Game"
@@ -85,11 +83,11 @@ async def get_vod_game_name(vod_id: str):
return game_id, game_name return game_id, game_name
async def get_streamer_vods(): async def get_streamer_vods() -> list:
await get_twitch() await config.get_twitch()
print(f"Starting VOD extraction for {USER.display_name}...") print(f"Starting VOD extraction for {config.USER.display_name}...")
vod_generator = TWITCH.get_videos(user_id=USER.id, first=100, video_type=VideoType.ALL) vod_generator = config.TWITCH.get_videos(user_id=config.USER.id, first=100, video_type=VideoType.ALL)
all_vods = [] all_vods = []
@@ -99,7 +97,7 @@ async def get_streamer_vods():
# Safely convert game_id to integer if possible, otherwise default to 0 # Safely convert game_id to integer if possible, otherwise default to 0
try: try:
clean_game_id = int(game_id) clean_game_id = game_id
except ValueError: except ValueError:
clean_game_id = 0 clean_game_id = 0
@@ -114,7 +112,7 @@ async def get_streamer_vods():
"game_id": clean_game_id, "game_id": clean_game_id,
"game_name": game_name, "game_name": game_name,
"stream_id": str(v.stream_id) if v.stream_id else "0", "stream_id": str(v.stream_id) if v.stream_id else "0",
"creator_name": CHANNEL_NAME, "creator_name": config.CHANNEL_NAME,
"clip_is": False, "clip_is": False,
} }
all_vods.append(vod_data) all_vods.append(vod_data)
@@ -123,20 +121,15 @@ async def get_streamer_vods():
print(f"\nFinished extracting VODs. Total gathered: {len(all_vods)}") print(f"\nFinished extracting VODs. Total gathered: {len(all_vods)}")
return all_vods return all_vods
async def get_streamer_clips(): async def get_streamer_clips() -> list:
await get_twitch() await config.get_twitch()
print(f"Starting clip extraction for {USER.display_name}...") print(f"Starting clip extraction for {config.USER.display_name}...")
clip_generator = TWITCH.get_clips(broadcaster_id=USER.id, first=100) clip_generator = config.TWITCH.get_clips(broadcaster_id=config.USER.id, first=100)
all_clips = [] all_clips = []
async for c in clip_generator: async for c in clip_generator:
game_name = await get_game_name_by_id(c.game_id) game_name = await get_game_name_by_id(c.game_id)
try:
clean_game_id = int(c.game_id)
except (ValueError, TypeError):
clean_game_id = 0
clip_data = { clip_data = {
"id": c.id, "id": c.id,
@@ -146,7 +139,7 @@ async def get_streamer_clips():
"duration": c.duration, "duration": c.duration,
"url": c.url, "url": c.url,
"thumbnail_url": c.thumbnail_url, "thumbnail_url": c.thumbnail_url,
"game_id": clean_game_id, "game_id": c.game_id,
"game_name": game_name, "game_name": game_name,
"stream_id": "0", "stream_id": "0",
"creator_name": c.creator_name, "creator_name": c.creator_name,
@@ -158,14 +151,13 @@ async def get_streamer_clips():
print(f"\nFinished extracting clips. Total gathered: {len(all_clips)}") print(f"\nFinished extracting clips. Total gathered: {len(all_clips)}")
return all_clips return all_clips
async def main(): async def video_info():
print("--- Script Started ---") print("--- Script Started ---")
db = database.Database()
vods_list = await get_streamer_vods() vods_list = await get_streamer_vods()
for v in vods_list: for v in vods_list:
db.insert_video_record( DB.insert_video_record(
v['id'], v['title'], v['created_at'], v['view_count'], v['duration'], v['id'], v['title'], v['created_at'], v['view_count'], v['duration'],
v['url'], v['thumbnail_url'], v['game_id'], v['game_name'], v['url'], v['thumbnail_url'], v['game_id'], v['game_name'],
v['stream_id'], v['creator_name'], v['clip_is'] v['stream_id'], v['creator_name'], v['clip_is']
@@ -177,7 +169,7 @@ async def main():
clips_list = await get_streamer_clips() clips_list = await get_streamer_clips()
for v in clips_list: for v in clips_list:
db.insert_video_record( DB.insert_video_record(
v['id'], v['title'], v['created_at'], v['view_count'], v['duration'], v['id'], v['title'], v['created_at'], v['view_count'], v['duration'],
v['url'], v['thumbnail_url'], v['game_id'], v['game_name'], v['url'], v['thumbnail_url'], v['game_id'], v['game_name'],
v['stream_id'], v['creator_name'], v['clip_is'] v['stream_id'], v['creator_name'], v['clip_is']
@@ -187,4 +179,4 @@ async def main():
print(f"Recent VOD: '{clips_list[0]['title']}'") print(f"Recent VOD: '{clips_list[0]['title']}'")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(video_info())
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
import sys
import shlex
import subprocess
def run_command(cmd_str: str, progress_prefix: str = "Progress", look_for: list = ["frame=", "time=", "fps=", "Rendering frame"]) -> bool:
"""Runs a system command, streams its output live, and reports errors on failure."""
args = shlex.split(cmd_str)
# Redirect stderr to stdout to catch all logging/progress in one stream
process = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
print(f"Executing: {cmd_str[:90]}...")
# Maintain a small buffer history to display context if a crash occurs
output_history = []
# Stream the output live to the terminal
while True:
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
clean_line = line.strip()
output_history.append(clean_line) # Keep history for error reporting
# Keep history slim by only keeping the last 20 lines
if len(output_history) > 20:
output_history.pop(0)
# Only print updates that show progress metrics to keep terminal clean
if any(metric in clean_line for metric in look_for):
sys.stdout.write(f"\r[{progress_prefix}] {clean_line}")
sys.stdout.flush()
elif "Error" in clean_line or "failed" in clean_line:
print(f"\n[Alert] {clean_line}")
print("\n") # New line after process finishes
# Evaluate success status
success = (process.returncode == 0)
if not success:
print(f"❌ Command failed with exit code: {process.returncode}")
print("--- Technical Error Details (Last 5 lines of output) ---")
# Print the last 5 captured lines to show the exact point of failure
for error_line in output_history[-5:]:
print(f" > {error_line}")
print("---------------------------------------------------------")
return success
+15
View File
@@ -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",
]
+36
View File
@@ -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()
@@ -1,45 +1,20 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json from typing import Any
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build from googleapiclient.discovery import build
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
def load_credentials(): import package.youtube.config as config
"""Load credentials from secrets.json (Reused from your previous flow)"""
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']
)
if credentials.expired:
credentials.refresh(Request())
return credentials
except FileNotFoundError:
print("Error: secrets.json not found!")
return None
except Exception as e:
print(f"Error loading credentials: {str(e)}")
return None
def get_my_uploads_playlist_id(youtube): def get_my_uploads_playlist_id(youtube: build) -> Any:
"""Retrieves the system upload playlist ID for the authenticated user's channel.""" """Retrieves the system upload playlist ID for the authenticated user's channel."""
try: try:
# mine=True automatically references the authorized account # mine=True automatically references the authorized account
request = youtube.channels().list(part="contentDetails", mine=True) request = youtube.channels().list(part="contentDetails", mine=True)
response = request.execute() response = request.execute()
if "items" in response and len(response["items"]) > 0: if "items" in response and len(response['items']) > 0:
return response["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"] return response['items'][0]['contentDetails']['relatedPlaylists']['uploads']
else: else:
print("No channel found for these credentials.") print("No channel found for these credentials.")
return None return None
@@ -47,7 +22,7 @@ def get_my_uploads_playlist_id(youtube):
print(f"API Error retrieving channel details: {str(e)}") print(f"API Error retrieving channel details: {str(e)}")
return None return None
def scan_channel_videos_for_tag(youtube, uploads_playlist_id: str, target_tag: str): 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. Loops through all channel video uploads and filters those possessing the target tag.
""" """
@@ -70,7 +45,7 @@ def scan_channel_videos_for_tag(youtube, uploads_playlist_id: str, target_tag: s
playlist_response = playlist_request.execute() playlist_response = playlist_request.execute()
video_ids_batch = [ video_ids_batch = [
item["snippet"]["resourceId"]["videoId"] item['snippet']['resourceId']['videoId']
for item in playlist_response.get("items", []) for item in playlist_response.get("items", [])
] ]
@@ -87,10 +62,10 @@ def scan_channel_videos_for_tag(youtube, uploads_playlist_id: str, target_tag: s
video_response = video_request.execute() video_response = video_request.execute()
for video in video_response.get("items", []): for video in video_response.get("items", []):
title = video["snippet"]["title"] title = video['snippet']['title']
video_id = video["id"] video_id = video['id']
# Tags are optional fields on YouTube; default to an empty list if absent # Tags are optional fields on YouTube; default to an empty list if absent
tags = video["snippet"].get("tags", []) tags = video['snippet'].get("tags", [])
# Normalize tags to lowercase for clean matching evaluation # Normalize tags to lowercase for clean matching evaluation
tags_lower = [tag.lower() for tag in tags] tags_lower = [tag.lower() for tag in tags]
@@ -120,12 +95,12 @@ def scan_channel_videos_for_tag(youtube, uploads_playlist_id: str, target_tag: s
return matched_videos return matched_videos
def main(): def find_tags():
credentials = load_credentials()
if not credentials: if not config.CREDENTIALS:
return return
youtube = build('youtube', 'v3', credentials=credentials) youtube = build('youtube', 'v3', credentials=config.CREDENTIALS)
# 1. Fetch your dynamic uploads playlist pointer # 1. Fetch your dynamic uploads playlist pointer
uploads_id = get_my_uploads_playlist_id(youtube) uploads_id = get_my_uploads_playlist_id(youtube)
@@ -144,4 +119,4 @@ def main():
print(f"{index}. {item['title']} -> https://youtu.be{item['id']}") print(f"{index}. {item['title']} -> https://youtu.be{item['id']}")
if __name__ == "__main__": if __name__ == "__main__":
main() find_tags()
+104
View File
@@ -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)
+142
View File
@@ -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.")
+8 -36
View File
@@ -1,16 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import json
import argparse import argparse
from enum import Enum from enum import Enum
from datetime import datetime from datetime import datetime
from google.oauth2.credentials import Credentials
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.http import MediaFileUpload from googleapiclient.http import MediaFileUpload
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
import package.youtube.config as config
class CategoryId(Enum): class CategoryId(Enum):
"""Official YouTube Category IDs for API Uploads.""" """Official YouTube Category IDs for API Uploads."""
@@ -30,34 +29,7 @@ class CategoryId(Enum):
SCIENCE_AND_TECHNOLOGY = "28" SCIENCE_AND_TECHNOLOGY = "28"
NONPROFITS_AND_ACTIVISM = "29" NONPROFITS_AND_ACTIVISM = "29"
def load_credentials(): def upload_video(file_path: str, title: str, category: CategoryId, description: str = "", privacyStatus: str = 'private', tags: list = None, release_time: datetime = None) -> bool:
"""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
def upload_video(file_path: str, title: str, category: CategoryId, description: str = "", privacyStatus: str = 'private', tags: list = None, release_time: datetime = None):
""" """
Upload a video to YouTube Upload a video to YouTube
@@ -78,16 +50,15 @@ def upload_video(file_path: str, title: str, category: CategoryId, description:
try: try:
# Check if file exists # Check if file exists
if not os.path.exists(file_path): if not os.path.exists(file_path):
print(f"Error: File not found: {file_path}") print(f"❌ Error: File not found: {file_path}")
return False return False
# Load credentials # Load credentials
credentials = load_credentials() if not config.CREDENTIALS:
if not credentials:
return False return False
# Create YouTube API client # Create YouTube API client
youtube = build('youtube', 'v3', credentials=credentials) youtube = build('youtube', 'v3', credentials=config.CREDENTIALS)
# Configure the status object dynamically # Configure the status object dynamically
status_body = { status_body = {
@@ -197,5 +168,6 @@ def main():
tags=parsed_tags, tags=parsed_tags,
release_time=release_datetime release_time=release_datetime
) )
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+54 -80
View File
@@ -1,96 +1,70 @@
aiohappyeyeballs==2.7.1
aiohttp==3.14.3
aiosignal==1.4.0
anyio==4.15.1
attrs==26.1.0 attrs==26.1.0
beautifulsoup4==4.14.3 av==18.1.0
certifi==2026.2.25 certifi==2026.7.22
cffi==2.0.0 cffi==2.1.1
chardet==5.2.0 charset-normalizer==3.5.1
charset-normalizer==3.4.7 click==8.5.0
click==8.4.2 cloudpickle==3.1.2
colorama==0.4.6 cryptography==50.0.1
cryptography==49.0.0 ctranslate2==4.8.2
cuda-bindings==13.3.1
cuda-pathfinder==1.5.6
cuda-toolkit==13.0.3.0
decorator==5.3.1 decorator==5.3.1
defusedxml==0.7.1 defusedxml==0.7.1
docopt==0.6.2 enum-tools==0.13.0
filelock==3.31.0 faster-whisper==1.2.1
fsspec==2026.6.0 filelock==3.32.6
google-api-core==2.31.0 flatbuffers==25.12.19
google-api-python-client==2.198.0 frozenlist==1.8.0
google-auth==2.56.0 fsspec==2026.7.0
google-auth-httplib2==0.4.0 google-api-core==2.36.0
google-auth-oauthlib==1.4.0 google-api-python-client==2.200.0
googleapis-common-protos==1.75.0 google-auth==2.58.0
google-auth-httplib2==0.4.2
google-auth-oauthlib==1.4.1
googleapis-common-protos==1.75.3
h11==0.16.0 h11==0.16.0
hf-xet==1.6.0
httpcore==1.0.9
httplib2==0.32.0 httplib2==0.32.0
idna==3.11 httpx==0.28.1
ImageIO==2.37.3 huggingface_hub==1.31.0
idna==3.19
ImageIO==2.37.4
imageio-ffmpeg==0.6.0 imageio-ffmpeg==0.6.0
Jinja2==3.1.6 joblib==1.6.0
joblib==1.5.3
llvmlite==0.48.0
MarkupSafe==3.0.3
more-itertools==11.1.0
moviepy==2.2.1 moviepy==2.2.1
mpmath==1.3.0 multidict==6.8.0
networkx==3.6.1 nltk==3.10.3
nltk==3.10.0 numpy==2.5.3
numba==0.66.0
numpy==2.4.6
nvidia-cublas==13.1.1.3
nvidia-cuda-cupti==13.0.85
nvidia-cuda-nvrtc==13.0.88
nvidia-cuda-runtime==13.0.96
nvidia-cudnn-cu13==9.20.0.48
nvidia-cufft==12.0.0.61
nvidia-cufile==1.15.1.6
nvidia-curand==10.4.0.35
nvidia-cusolver==12.0.4.66
nvidia-cusparse==12.6.3.3
nvidia-cusparselt-cu13==0.8.1
nvidia-nccl-cu13==2.29.7
nvidia-nvjitlink==13.3.33
nvidia-nvshmem-cu13==3.4.5
nvidia-nvtx==13.0.85
oauthlib==3.3.1 oauthlib==3.3.1
openai-whisper==20250625 onnxruntime==1.30.0
outcome==1.3.0.post0 opencv-python==5.0.0.93
packaging==26.2 opentelemetry-api==1.44.0
packaging==26.3
pillow==11.3.0 pillow==11.3.0
pipreqs==0.4.13
proglog==0.1.12 proglog==0.1.12
proto-plus==1.28.1 propcache==0.5.2
protobuf==7.35.1 proto-plus==1.28.4
protobuf==7.36.1
pyasn1==0.6.4 pyasn1==0.6.4
pyasn1_modules==0.4.2 pyasn1_modules==0.4.2
pycparser==3.0 pycparser==3.0
Pygments==2.21.0
pyparsing==3.3.2 pyparsing==3.3.2
PySocks==1.7.1 python-dateutil==2.9.0.post0
python-apt==3.0.0 python-dotenv==1.2.3
python-debian==1.0.1 PyYAML==6.0.3
python-debianbts==4.1.1 regex==2026.9.10
python-dotenv==1.2.2 requests==2.34.2
regex==2026.7.19
reportbug==13.2.0
requests==2.33.1
requests-oauthlib==2.0.0 requests-oauthlib==2.0.0
selenium==4.43.0 six==1.17.0
setuptools==83.0.0 tokenizers==0.23.2
sniffio==1.3.1 tqdm==4.70.1
sortedcontainers==2.4.0 twitchAPI==4.5.0
soupsieve==2.8.3 typing_extensions==4.16.0
sympy==1.14.0
tiktoken==0.13.0
torch==2.13.0
tqdm==4.67.3
trio==0.33.0
trio-websocket==0.12.2
triton==3.7.1
typing_extensions==4.15.0
uritemplate==4.2.0 uritemplate==4.2.0
urllib3==2.6.3 urllib3==2.7.0
webdriver-manager==4.0.2 yarl==1.24.5
websocket-client==1.9.0
wheel==0.46.1
wsproto==1.3.2
yarg==0.1.10
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env python3
import os
import subprocess
import whisper
import linux
from moviepy import VideoFileClip
from whisper.utils import get_writer
model = whisper.load_model("base")
def extract_audio(video_path: str, audio_temp_path: str):
# Create a temporary path for a sanitized copy of the video
sanitized_video_path = video_path.replace(".mp4", "_clean.mp4")
print("Sanitizing video metadata for MoviePy parser...")
# -map_chapters -1 removes chapter layouts that break the parser.
# -sn strips text/subtitle streams that crash MoviePy.
# -c copy copies video and audio instantly without quality loss.
linux.run_command(f"ffmpeg -y -i {video_path} -map_chapters -1 -sn -c copy {sanitized_video_path}")
print("Extracting uncompressed WAV audio...")
try:
# Load the sanitized file instead of the raw Twitch clip
with VideoFileClip(sanitized_video_path) as video:
video.audio.write_audiofile(
audio_temp_path,
fps=16000,
codec="pcm_s16le",
ffmpeg_params=["-ac", "1"]
)
finally:
# Always clean up the temporary sanitized video on Windows 11
if os.path.exists(sanitized_video_path):
os.remove(sanitized_video_path)
def transcribe_to_srt(audio_path: str, output_directory: str, output_filename: str):
print("Transcribing audio...")
result = model.transcribe(audio_path)
print("Creating SRT file...")
srt_writer = get_writer("srt", output_directory)
srt_writer(result, output_filename, {})
print(f"SRT subtitle file saved in: {output_directory}")
if os.path.exists(audio_path):
os.remove(audio_path)
if __name__ == "__main__":
video_path = "my_video.mp4"
audio_temp_path = "temp_audio.wav" # Changed extension to .wav
output_dir = os.getcwd()
output_prefix = "my_video_subtitles"
extract_audio(video_path, audio_temp_path)
transcribe_to_srt(audio_temp_path, output_dir, output_prefix)
if os.path.exists(audio_temp_path):
os.remove(audio_temp_path)
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
import requests
import os
import time
import csv
import linux
from database import Database
CHANNEL_NAME = 'teampgp'
DB = None
def write_csv(data, file_name):
# Open file with newline='' to prevent extra blank rows across platforms
with open(file_name, "w", newline="", encoding="utf-8") as file:
writer = csv.writer(file)
# Write all rows at once
writer.writerows(data)
def download():
"""Find all undownload videos and download them."""
undownloaded = DB.get_undownloaded()
print(f"Download...")
for id, title, created_at, view_count, duration, url, thumbnail_url, game_id, game_name, stream_id, creator_name, clip_is, downloaded, uploaded_yt, uploaded_yt_chats, uploaded_yt_shorts in undownloaded:
data = [[id, title, created_at, view_count, duration, url, thumbnail_url, game_id, game_name, stream_id, creator_name, clip_is, downloaded, uploaded_yt, uploaded_yt_chats, uploaded_yt_shorts]]
print(f"ID: {id} | Date: {created_at} | Game: {game_name} | Title: {title}")
if not clip_is:
output = linux.run_command(f"TwitchDownloaderCLI videodownload --id {id} -o download/videos/{id}/{id}.mp4 --collision Overwrite")
output2 = linux.run_command(f"TwitchDownloaderCLI chatdownload --id {id} -o download/videos/{id}/{id}_chat.json -E --collision Overwrite")
write_csv(data, f"download/videos/{id}/{id}.csv")
elif clip_is:
output = linux.run_command(f"TwitchDownloaderCLI clipdownload --id {id} -o download/clips/{id}/{id}.mp4 --collision Overwrite")
write_csv(data, f"download/clips/{id}/{id}.csv")
time.sleep(1)
if output["success"] is True:
print(f"ID: {id} | Date: {created_at} | Game: {game_name} | Title: {title} | was successful")
DB.mark_as_downloaded(id)
else:
print(f"ID: {id} | Download Process failed. {output["stdout"]}. Error: {output["stderr"]}")
print(f"Finished Downloading...")
if __name__ == "__main__":
DB = Database()
download()
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env python3
import re
import string
from collections import Counter
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
import nltk
# Download necessary NLTK data
nltk.download('punkt', quiet=True)
nltk.download('stopwords', quiet=True)
nltk.download('punkt_tab', quiet=True)
def extract_text_from_srt(file_path: str):
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 get_top_hashtags(srt_file_path: str, top_n: int = 10):
raw_text = extract_text_from_srt(srt_file_path)
# Lowercase and remove punctuation
raw_text = raw_text.lower()
raw_text = raw_text.translate(str.maketrans('', '', string.punctuation))
# Tokenize and remove stopwords
words = word_tokenize(raw_text)
stop_words = set(stopwords.words('english'))
# Filter for alphabetical words longer than 3 characters that aren't stop words
filtered_words = [
word for word in words
if word.isalpha() and word not in stop_words and len(word) > 3
]
# Get frequency and create hashtags
word_counts = Counter(filtered_words)
top_words = word_counts.most_common(top_n)
hashtags = [f"{word[0]}" for word in top_words]
return hashtags
if __name__ == "__main__":
# Example usage
# Replace 'your_video.srt' with the path to your file
results = get_top_hashtags('your_video.srt', top_n=10)
print("Trending Hashtags:", results)
-152
View File
@@ -1,152 +0,0 @@
#!/usr/bin/env python3
import argparse
import sys
import linux
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 apply_gaussian_blur(frame, radius: int = 30):
"""
Transforms a single NumPy array frame using PIL's true GaussianBlur filter.
"""
# Convert numpy array to PIL Image
image = Image.fromarray(frame)
# Apply high-quality true Gaussian Blur
blurred_image = image.filter(ImageFilter.GaussianBlur(radius=radius))
# Return back as a numpy array for MoviePy
return np.array(blurred_image)
def fit_to_9_16_letterbox(input_path: str, top_text: str = "TOP TEXT", bottom_text: str = "BOTTOM TEXT", use_blur: bool = True):
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}"
print("🧼 Sanitizing video metadata streams inside an automated safe context...")
with tempfile.NamedTemporaryFile(suffix=input_file.suffix, delete=False) as temp_file:
temp_path = temp_file.name
bg_scaled = None
bg_cropped = None
background_layer = None
try:
linux.run_command(f"ffmpeg -y -i {input_file} -map_chapters -1 -sn -c copy {temp_path}")
# Load video
clip = VideoFileClip(temp_path)
# Standard vertical 9:16 canvas sizes
canvas_w = 1080
canvas_h = 1920
# Background logic
if use_blur:
print("📐 Scaling, cropping, and blurring background layer...")
bg_scaled = clip.resized(height=canvas_h)
bg_cropped = bg_scaled.cropped(width=canvas_w, x_center=bg_scaled.w / 2)
background_layer = bg_cropped.transform(lambda gf, t: apply_gaussian_blur(gf(t), radius=35))
else:
print("⚫ Creating solid black background canvas...")
background_layer = ColorClip(size=(canvas_w, canvas_h), color=(0, 0, 0), duration=clip.duration)
print("📐 Shrinking foreground video width to fit the 1080 wide canvas...")
foreground_clip = clip.resized(width=canvas_w)
print("✍️ Creating multi-line top title text clip...")
# FIXED: Added 'method="caption"' and increased vertical size to 300
title_clip = TextClip(
text=top_text,
font_size=55, # Slightly smaller to accommodate paragraphs comfortably
color="white",
font="DejaVuSans-Bold",
text_align="center",
size=(canvas_w - 100, 300), # Subtracted 100px for safety margins on left/right edges
method="caption", # Forces text to wrap cleanly onto a new line
duration=clip.duration
)
# Position adjusted to center the taller 300px box in the upper section
positioned_top_text = title_clip.with_position(("center", 180))
print("✍️ Creating multi-line bottom text clip...")
# FIXED: Added 'method="caption"' and increased vertical size to 300
bottom_clip = TextClip(
text=bottom_text,
font_size=55,
color="white",
font="DejaVuSans-Bold",
text_align="center",
size=(canvas_w - 100, 300), # Left/right margins included
method="caption", # Forces text to wrap cleanly onto a new line
duration=clip.duration
)
# Position adjusted to center the taller 300px box in the lower section
positioned_bottom_text = bottom_clip.with_position(("center", 1430))
# Composite layers
final_clip = CompositeVideoClip(
[
background_layer,
foreground_clip.with_position("center"),
positioned_top_text,
positioned_bottom_text
]
).with_audio(clip.audio)
print("🎬 Rendering final vertical composition...")
final_clip.write_videofile(
str(output_path),
codec="libx264",
audio_codec="aac",
fps=clip.fps
)
# Clean up file locks safely
clip.close()
if bg_scaled: bg_scaled.close()
if bg_cropped: bg_cropped.close()
background_layer.close()
foreground_clip.close()
title_clip.close()
bottom_clip.close()
final_clip.close()
finally:
temp_file_path = Path(temp_path)
if temp_file_path.exists():
temp_file_path.unlink()
print(f"🎉 Text overlay video saved to: {output_path}")
def main():
# Set up the command-line argument parser
parser = argparse.ArgumentParser(description="TeamPGP Clip Processing and Upload Pipeline")
# Add optional arguments
parser.add_argument('--convert', type=str, metavar='CLIP_PATH', help='Path to a video file to convert to a 9:16 Short')
parser.add_argument('--upload', action='store_true', help='Process and upload pending Shorts in the database to YouTube')
args = parser.parse_args()
# If no flags are provided, show help text and exit
if not args.convert and not args.upload:
parser.print_help()
sys.exit("\n❌ Error: You must provide at least one action flag (--convert or --upload).")
# Execute conversion step if path is provided
if args.convert:
clip_by = "joelmckinney"
#convert_to_short(args.convert)
fit_to_9_16_letterbox(args.convert, "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp", f"Clipped By: {clip_by}.", True)
# Execute database upload step if flag is provided
if args.upload:
upload_shorts()
if __name__ == "__main__":
main()