Compare commits

18 Commits
Author SHA1 Message Date
SumGuyV5 7f1f1e7b4f add hight var to chatrender for 720p videos 2026-08-22 13:13:54 +00:00
SumGuyV5 33ea2b7fcf update 2026-08-22 03:39:48 +00:00
SumGuyV5 33d78759fe putting it all togeather 2026-08-04 02:34:16 +00:00
SumGuyV5 d6503b7d92 database , chat vod and shorts update error messages 2026-07-31 16:56:40 +00:00
SumGuyV5 736fb47e7a formating changes 2026-07-29 21:36:08 +00:00
SumGuyV5 93b480ae8d update linux run_command to give more output when stderrs out 2026-07-29 04:00:43 +00:00
SumGuyV5 b6d9b56848 update Database to use default in init 2026-07-29 03:03:29 +00:00
SumGuyV5 500fe87614 update and make test for transcribe and chat_vod and shorts 2026-07-29 01:58:18 +00:00
SumGuyV5 bbac53d15d add build_videos file 2026-07-28 04:14:59 +00:00
SumGuyV5 9203b0f88c exparmental run_command in linux.py migrate_sql1.py 2026-07-28 03:27:26 +00:00
SumGuyV5 6882b7ae04 update files to change [""] to [''] 2026-07-24 12:22:35 +00:00
SumGuyV5 0f96eedc54 update requirements.txt 2026-07-24 12:04:02 +00:00
SumGuyV5 83f7bbead8 uncomment the mark_as_downloaded 2026-07-24 04:54:39 +00:00
SumGuyV5 1257a15eb9 update chmod 755 and addes twitch_download_videos.py 2026-07-24 04:09:12 +00:00
SumGuyV5 765eaf5026 file name change 2026-07-23 17:34:36 +00:00
SumGuyV5 4934ddb3f8 update and rename twitch_vod.py and use httpx for async 2026-07-23 17:34:12 +00:00
SumGuyV5 c8d12469e3 Update Database to use new table fromat and twitch api 2026-07-23 15:32:40 +00:00
SumGuyV5 81e76b25d0 Made some chanages and now Can't remmebr what I did 2026-07-23 04:55:36 +00:00
24 changed files with 1740 additions and 741 deletions
+5
View File
@@ -4,3 +4,8 @@
database.db database.db
save save
__pycache__ __pycache__
twitch_secrets.json
.vscode/settings.json
download
output.log
*.mp4
Executable
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import asyncio
import json
import os
import webbrowser
from twitchAPI.twitch import Twitch
from twitchAPI.oauth import UserAuthenticator
from twitchAPI.type import AuthScope
SECRETS_FILE = "twitch_secrets.json"
def load_credentials():
"""Loads existing Client ID and Secret from your JSON file."""
if not os.path.exists(SECRETS_FILE):
raise FileNotFoundError(f"Could not find {SECRETS_FILE} in this directory.")
with open(SECRETS_FILE, "r") as f:
data = json.load(f)
return data.get("client_id"), data.get("client_secret")
def save_token_to_json(token):
"""Saves the generated token into twitch_secrets.json under 'manual_token'."""
with open(SECRETS_FILE, "r") as f:
data = json.load(f)
# Inject the new token
data["manual_token"] = token
with open(SECRETS_FILE, "w") as f:
json.dump(data, f, indent=4)
print(f"\n[SUCCESS] Token saved inside '{SECRETS_FILE}' under 'manual_token'!")
async def main():
try:
client_id, client_secret = load_credentials()
if not client_id or not client_secret:
print("[ERROR] Please add your client_id and client_secret to the JSON file first.")
return
print("Initializing local connection loop...")
# Initialize official Twitch connection interface
twitch = await Twitch(client_id, client_secret)
# Scopes: We leave this empty [] since VOD collection only requires basic public clearance
scopes = []
# Create an authenticator that automatically sets up http://localhost:17563
auth = UserAuthenticator(twitch, scopes, url="http://localhost:17563")
# Request authentication URL
auth_url = auth.return_auth_url()
print(f"\nIf your browser does not open automatically, copy and paste this URL into your browser:\n{auth_url}\n")
# Open your system default browser to let you manually click "Authorize"
webbrowser.open(auth_url)
print("Waiting for you to click 'Authorize' in your web browser...")
# The script halts here, running a local background server until you click authorize
token, refresh_token = await auth.authenticate()
print(f"\nSuccessfully generated Token: {token}")
# Save it right back into your configuration file
save_token_to_json(token)
# Gracefully shut down the library connection
await twitch.close()
except Exception as e:
print(f"\n[ERROR] An error occurred: {e}")
print("Double-check that http://localhost:17563 is added to your Twitch Dev Console.")
if __name__ == "__main__":
# Run the asynchronous loop
asyncio.run(main())
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
import database
import youtube_short
import transcribe_video
import twitch_chat_vod
DB = None
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("====================================================")
twitch_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("====================================================")
youtube_short.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 main():
global DB
DB = database.Database()
build_shorts()
build_transcribe()
build_chat_video()
if __name__ == "__main__":
main()
Regular → Executable
+122 -108
View File
@@ -1,148 +1,162 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from datetime import date as datetime_date
from pathlib import Path from pathlib import Path
from typing import Any
class Database: class Database:
def __init__(self, table: str): def __init__(self, db_path: str = "database.db"):
self.table = table 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 = ""
# 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 self.table == "vods":
self.columns = "id, date, title, gamename, downloaded, uploaded_yt, chats_upload_yt"
elif self.table == "clips":
self.columns = "slug, date, title, gamename, clip_by, view_count, downloaded, uploaded_yt, shorts_upload_yt"
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
try:
self.close_database() self.close_database()
except:
pass
def create_database(self): def create_database(self):
"""Creates a table structured explicitly for Twitch clip properties.""" """Creates a table structured explicitly for Twitch videos properties."""
self.CURSOR.execute( self.cursor.execute("""
""" CREATE TABLE IF NOT EXISTS twitch_videos (
CREATE TABLE IF NOT EXISTS clips ( id TEXT PRIMARY KEY,
slug TEXT PRIMARY KEY,
date TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
gamename TEXT NOT NULL, created_at TEXT NOT NULL,
clip_by TEXT NOT NULL,
view_count INTEGER NOT NULL, view_count INTEGER NOT NULL,
downloaded INTEGER NOT NULL, duration TEXT NOT NULL,
uploaded_yt INTEGER NOT NULL, url TEXT NOT NULL,
shorts_upload_yt INTEGER 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
) )
""")
"""Creates a table structured explicitly for Twitch vods properties.""" self.conn.commit()
self.CURSOR.execute(
"""
CREATE TABLE IF NOT EXISTS vods (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
title TEXT NOT NULL,
gamename TEXT NOT NULL,
downloaded INTEGER NOT NULL,
uploaded_yt INTEGER NOT NULL,
chats_upload_yt INTEGER NOT NULL
)
"""
)
self.CONN.commit()
def close_database(self): def close_database(self):
"""Commit before we close.""" """Commit before we close."""
self.CONN.commit() if self.conn:
self.CONN.close() self.conn.commit()
self.conn.close()
def __mark_as(self, record_id: int, set_sql: str): def __mark_as(self, record_id: str, set_row: str, mark: str = "1" ):
id = "id" self.cursor.execute(
f"UPDATE twitch_videos SET {set_row} = ? WHERE id = ?",
(mark, record_id))
self.conn.commit()
if self.table == "clips": def mark_as_uploaded_shorts(self, record_id: str):
id = "slug" """Flags a specific row record to uploaded_yt_shorts (1)."""
self.__mark_as(record_id, "uploaded_yt_shorts")
self.CURSOR.execute( def mark_as_uploaded_chats(self, record_id: str):
f"UPDATE {self.table} SET {set_sql} = 1 WHERE {id} = ?", """Flags a specific row record to uploaded_yt_chats (1)."""
(record_id,) self.__mark_as(record_id, "uploaded_yt_chats")
)
self.CONN.commit()
def mark_as_uploaded_shorts_chats(self, record_id: int):
"""Flags a specific row record to uploaded (1)."""
set_sql = "chats_upload_yt"
if self.table == "clips":
set_sql = "shorts_uploaded_yt"
self.__mark_as(record_id, set_sql)
def mark_as_uploaded(self, record_id: int):
"""Flags a specific row record to uploaded (1)."""
def mark_as_uploaded_yt(self, record_id: str):
"""Flags a specific row record to uploaded_yt (1)."""
self.__mark_as(record_id, "uploaded_yt") self.__mark_as(record_id, "uploaded_yt")
def mark_as_downloaded(self, record_id: int): def mark_as_downloaded(self, record_id: str):
"""Updates the downloaded status to True (1) for a specific record ID.""" """Flags a specific row record to downloaded (1)."""
self.__mark_as(record_id, "downloaded") self.__mark_as(record_id, "downloaded")
def get_unuploaded_shorts_chats(self): def unmark_as_download(self, record_id: str):
"""Retrieves all clip rows remaining to be chat uploaded.""" """Flags a specific row record to downloaded (0)."""
self.__mark_as(record_id, "downloaded", "0")
where_sql = "chats_upload_yt" def __get_unuploaded(self, set_row: str, also: str = "") -> list[Any]:
if self.table == "clips": """Retrieve all rows that were download but not uploaded"""
where_sql = "shorts_uploaded_yt" self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE downloaded = 1 AND {set_row} = 0 {also}")
return self.cursor.fetchall()
self.CURSOR.execute(f"SELECT {self.columns} FROM {self.table} WHERE downloaded = 1 AND uploaded_yt = 1 AND {where_sql} = 0") def get_unuploaded_shorts(self) -> list[Any]:
return self.CURSOR.fetchall() """Retrieve all rows that were download but not uploaded_yt_shorts"""
return self.__get_unuploaded("uploaded_yt_shorts", "AND clip_is = 1")
def get_unuploaded(self): def get_unuploaded_chats(self) -> list[Any]:
"""Retrieves all clip rows remaining to be uploaded.""" """Retrieve all rows that were download but not uploaded_yt_chats"""
self.CURSOR.execute(f"SELECT {self.columns} FROM {self.table} WHERE downloaded = 1 AND uploaded_yt = 0") return self.__get_unuploaded("uploaded_yt_chats", "AND clip_is = 0")
return self.CURSOR.fetchall()
def get_undownloaded(self): def get_unuploaded(self) -> list[Any]:
"""Retrieves all rows where downloaded status is False (0).""" """Retrieve all rows that were download but not uploaded_yt"""
self.CURSOR.execute(f"SELECT {self.columns} FROM {self.table} WHERE downloaded = 0") return self.__get_unuploaded("uploaded_yt")
return self.CURSOR.fetchall()
def insert_vods_record(self, record_id: int, record_date_str: datetime_date, title: str, gamename: str): def get_undownloaded(self) -> list[Any]:
"""Inserts a record with ID, date, gamename, and title into a SQLite database.""" """Retrieves all rows that were not downloaded"""
self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE downloaded = 0")
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(
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,
creator_name: str, clip_is: bool
):
try: try:
clean_date = datetime.strptime(record_date_str, "%Y-%m-%dT%H:%M:%SZ").date() dt_obj = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ")
except ValueError: clean_datetime = dt_obj.strftime("%Y-%m-%d %H:%M:%S")
clean_date = record_date_str except (ValueError, TypeError):
# Connects to database file (creates it if missing) clean_datetime = created_at
# Inserts data using parameterized queries to prevent SQL injection # Explicitly defining columns removes the security risk and column-count bug
self.CURSOR.execute( query = """
f"INSERT OR IGNORE INTO {self.table} ({self.columns}) VALUES (?, ?, ?, ?, ?, ?, ?)", INSERT INTO twitch_videos (
(record_id, str(clean_date), title, gamename, False, False, False), id, title, created_at, view_count, duration, url, thumbnail_url,
game_id, game_name, stream_id, creator_name, clip_is
) 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 = (
id, title, clean_datetime, view_count, duration, url, thumbnail_url,
game_id, game_name, stream_id, creator_name, clip_is
) )
# Saves changes and closes the connection
self.CONN.commit()
def insert_clips_record(self, slug: str, record_date_str: str, title: str, gamename: str, clip_by: str, views: int):
"""Cleans up ISO-8601 strings into unified date structures for the database."""
try: try:
clean_date = datetime.strptime(record_date_str, "%Y-%m-%dT%H:%M:%SZ").date() self.cursor.execute(query, values)
except ValueError: self.conn.commit()
clean_date = record_date_str except Exception as e:
# Prevent silent failures if the database connection drops
self.CURSOR.execute( print(f"❌ Database insertion failed: {e}")
f"INSERT OR IGNORE INTO {self.table} ({self.columns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", self.conn.rollback()
(slug, str(clean_date), title, gamename, clip_by, views, False, False, False),
)
self.CONN.commit()
Regular → Executable
+6 -6
View File
@@ -38,8 +38,8 @@ def get_my_uploads_playlist_id(youtube):
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
@@ -70,7 +70,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 +87,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]
Executable
+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
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
import asyncio
import twitch_video_info
import twitch_download_videos
import twitch_download_thumbnails
import build_videos
if __name__ == "__main__":
# Get Twitch Video Info
asyncio.run(twitch_video_info.main())
# Download Twitch Videos
twitch_download_videos.main()
twitch_download_thumbnails.main()
#Build
build_videos.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()
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import 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 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()
+41 -29
View File
@@ -1,39 +1,51 @@
apt-listchanges==4.8 aiohappyeyeballs==2.7.1
aiohttp==3.14.2
aiosignal==1.4.0
anyio==4.14.2
attrs==26.1.0 attrs==26.1.0
beautifulsoup4==4.14.3 beautifulsoup4==4.15.0
certifi==2026.2.25 certifi==2026.7.22
cffi==2.0.0 cffi==2.1.0
chardet==5.2.0 charset-normalizer==3.4.9
charset-normalizer==3.4.7 chat-downloader==0.2.8
click==8.4.2 click==8.4.2
colorama==0.4.6 colorlog==6.12.0
cryptography==49.0.0 cryptography==49.0.0
cuda-bindings==13.3.1 cuda-bindings==13.3.1
cuda-pathfinder==1.5.6 cuda-pathfinder==1.6.0
cuda-toolkit==13.0.3.0 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 docstring_parser==0.18.0
filelock==3.31.0 enum-tools==0.13.0
filelock==3.32.0
frozenlist==1.8.0
fsspec==2026.6.0 fsspec==2026.6.0
google-api-core==2.31.0 git-filter-repo==2.47.0
google==3.0.0
google-api-core==2.32.0
google-api-python-client==2.198.0 google-api-python-client==2.198.0
google-auth==2.56.0 google-auth==2.56.2
google-auth-httplib2==0.4.0 google-auth-httplib2==0.4.0
google-auth-oauthlib==1.4.0 google-auth-oauthlib==1.4.0
googleapis-common-protos==1.75.0 googleapis-common-protos==1.75.0
h11==0.16.0 h11==0.16.0
httpcore==1.0.9
httplib2==0.32.0 httplib2==0.32.0
idna==3.11 httpx==0.28.1
ImageIO==2.37.3 idna==3.18
ImageIO==2.37.4
imageio-ffmpeg==0.6.0 imageio-ffmpeg==0.6.0
isodate==0.7.2
Jinja2==3.1.6 Jinja2==3.1.6
joblib==1.5.3 joblib==1.5.3
llvmlite==0.48.0 llvmlite==0.48.0
lxml==6.1.1
MarkupSafe==3.0.3 MarkupSafe==3.0.3
more-itertools==11.1.0 more-itertools==11.1.0
moviepy==2.2.1 moviepy==2.2.1
mpmath==1.3.0 mpmath==1.3.0
multidict==6.7.1
networkx==3.6.1 networkx==3.6.1
nltk==3.10.0 nltk==3.10.0
numba==0.66.0 numba==0.66.0
@@ -56,42 +68,42 @@ nvidia-nvtx==13.0.85
oauthlib==3.3.1 oauthlib==3.3.1
openai-whisper==20250625 openai-whisper==20250625
outcome==1.3.0.post0 outcome==1.3.0.post0
packaging==26.2
pillow==11.3.0 pillow==11.3.0
pipreqs==0.4.13 pip_system_certs==5.3
proglog==0.1.12 proglog==0.1.12
propcache==0.5.2
proto-plus==1.28.1 proto-plus==1.28.1
protobuf==7.35.1 protobuf==7.35.1
pyasn1==0.6.4 pyasn1==0.6.4
pyasn1_modules==0.4.2 pyasn1_modules==0.4.2
pycountry==26.2.16
pycparser==3.0 pycparser==3.0
pycryptodome==3.23.0
Pygments==2.20.0
pyparsing==3.3.2 pyparsing==3.3.2
PySocks==1.7.1 PySocks==1.7.1
python-apt==3.0.0 python-dateutil==2.9.0.post0
python-debian==1.0.1
python-debianbts==4.1.1
python-dotenv==1.2.2 python-dotenv==1.2.2
regex==2026.7.19 regex==2026.7.19
reportbug==13.2.0 requests==2.34.2
requests==2.33.1
requests-oauthlib==2.0.0 requests-oauthlib==2.0.0
selenium==4.43.0
setuptools==83.0.0 setuptools==83.0.0
six==1.17.0
sniffio==1.3.1 sniffio==1.3.1
sortedcontainers==2.4.0 sortedcontainers==2.4.0
soupsieve==2.8.3 soupsieve==2.9.1
streamlink==8.4.0
sympy==1.14.0 sympy==1.14.0
tiktoken==0.13.0 tiktoken==0.13.0
torch==2.13.0 torch==2.13.0
tqdm==4.67.3 tqdm==4.69.0
trio==0.33.0 trio==0.33.0
trio-websocket==0.12.2 trio-websocket==0.12.2
triton==3.7.1 triton==3.7.1
typing_extensions==4.15.0 twitchAPI==4.5.0
typing_extensions==4.16.0
uritemplate==4.2.0 uritemplate==4.2.0
urllib3==2.6.3 urllib3==2.7.0
webdriver-manager==4.0.2
websocket-client==1.9.0 websocket-client==1.9.0
wheel==0.46.1
wsproto==1.3.2 wsproto==1.3.2
yarg==0.1.10 yarl==1.24.5
Regular → Executable
+103 -51
View File
@@ -1,67 +1,119 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import os import os
import sys
import shutil
import subprocess import subprocess
import whisper from pathlib import Path
from moviepy import VideoFileClip from faster_whisper import WhisperModel
from whisper.utils import get_writer from faster_whisper.utils import format_timestamp
model = whisper.load_model("base") # Prevent OpenMP thread conflicts from crashing the script
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
def extract_audio(video_path: str, audio_temp_path: str): # 5 minutes per chunk (300 seconds) keeps RAM usage low and stable
# Create a temporary path for a sanitized copy of the video CHUNK_DURATION_SEC = 300
sanitized_video_path = video_path.replace(".mp4", "_clean.mp4")
print("Sanitizing video metadata for MoviePy parser...") print("🔧 Initializing C++ Engine...")
# -map_chapters -1 removes chapter layouts that break the parser. model = WhisperModel(
# -sn strips text/subtitle streams that crash MoviePy. "base",
# -c copy copies video and audio instantly without quality loss. device="cpu",
cleanup_cmd = [ 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, "ffmpeg", "-y", "-i", video_path,
"-map_chapters", "-1", "-sn", "-vn", "-ac", "1", "-ar", "16000",
"-c", "copy", sanitized_video_path "-acodec", "pcm_s16le", "-sn", "-map_chapters", "-1",
"-f", "segment", "-segment_time", str(CHUNK_DURATION_SEC),
chunk_pattern
] ]
# Run the sanitization process silently result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
subprocess.run(cleanup_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 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()
print("Extracting uncompressed WAV audio...")
try: try:
# Load the sanitized file instead of the raw Twitch clip # Step 1: Split audio into bite-sized pieces
with VideoFileClip(sanitized_video_path) as video: audio_chunks = extract_audio_and_chunk(str(video_path_obj), temp_dir)
video.audio.write_audiofile( if not audio_chunks:
audio_temp_path, print("❌ Error: No audio chunks were generated.")
fps=16000, return
codec="pcm_s16le",
ffmpeg_params=["-ac", "1"] 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: finally:
# Always clean up the temporary sanitized video on Windows 11 # Clean up the temporary folder entirely
if os.path.exists(sanitized_video_path): if temp_dir.exists():
os.remove(sanitized_video_path) shutil.rmtree(temp_dir)
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__": if __name__ == "__main__":
video_path = "my_video.mp4" target_video = "download/videos/2813112936/2813112936.mp4"
audio_temp_path = "temp_audio.wav" # Changed extension to .wav if not os.path.exists(target_video):
print(f"❌ System Error: Target video file does not exist at path: {target_video}")
output_dir = os.getcwd() else:
output_prefix = "my_video_subtitles" transcribe_to_srt(target_video, force=True)
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)
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
import os
import cv2
import linux
def get_video_height(video_path: str) -> int:
# 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 = linux.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.")
os.remove(temp_chat)
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 = linux.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)
Regular → Executable
+93 -60
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import requests import requests
import os import os
import subprocess import linux
import time
from database import Database from database import Database
@@ -14,21 +15,6 @@ import transcribe_video
CHANNEL_NAME = 'teampgp' # Replace with the streamer's username CHANNEL_NAME = 'teampgp' # Replace with the streamer's username
DB = None DB = None
def run_linux_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}
def transcribe(id: str): def transcribe(id: str):
"""Transcribes the Video File.""" """Transcribes the Video File."""
video_file = f"save/{DB.table}/{id}/{id}.mp4" video_file = f"save/{DB.table}/{id}/{id}.mp4"
@@ -61,34 +47,36 @@ def top_hashtags(id: str):
return tags return tags
def download(): def download():
"""Find all undownload vods and download them.""" """Find all undownload videos and download them."""
undownloaded = DB.get_undownloaded() undownloaded = DB.get_undownloaded()
print(f"Download {DB.table}...") print(f"Download {DB.table}...")
if DB.table == "vods": if DB.table == "videos":
for record_id, record_date, title, game_name, downloaded, uploaded_yt, chat_upload_yt in undownloaded: for record_id, record_date, title, game_name, downloaded, uploaded_yt, chat_upload_yt in undownloaded:
print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title}") print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title}")
output = run_linux_command(f"TwitchDownloaderCLI videodownload --id {record_id} -o save/vods/{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 = run_linux_command(f"TwitchDownloaderCLI chatdownload --id {record_id} -o save/vods/{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")
if output["success"] is True: time.sleep(1)
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:
print(f"Slug: {slug} | Date: {record_date} | Game: {game_name} | By: {clip_by} | Views: {views} | Title: {title}") print(f"Slug: {slug} | Date: {record_date} | Game: {game_name} | By: {clip_by} | Views: {views} | Title: {title}")
# Uses standard clipdownload directive # Uses standard clipdownload directive
output = run_linux_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)
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}...")
@@ -109,11 +97,11 @@ def upload():
upload_queue = [] upload_queue = []
if DB.table == "vods": if DB.table == "videos":
for record_id, record_date, title, game_name, downloaded, uploaded_yt, chats_upload_yt in unuploaded: for record_id, record_date, title, game_name, downloaded, uploaded_yt, chats_upload_yt in unuploaded:
print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title}") print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title}")
file_path = f"save/vods/{record_id}/{record_id}.mp4" file_path = f"save/videos/{record_id}/{record_id}.mp4"
description = f"Game: {game_name}, on {record_date}, #VODS {twitch_datetime}" description = f"Game: {game_name}, on {record_date}, #VODS {twitch_datetime}"
tags = list(base_tags) tags = list(base_tags)
tags.extend([f'{game_name}', 'twitch_vods', 'vods']) tags.extend([f'{game_name}', 'twitch_vods', 'vods'])
@@ -161,10 +149,14 @@ def get_vod_ids_simplified(channel_name: str):
"Content-Type": "text/plain" "Content-Type": "text/plain"
} }
vods_query_string = """ videos_query_string = """
query GetChannelVideos($login: String!, $limit: Int!) { query GetChannelVideos($login: String!, $limit: Int!, $after: Cursor) {
user(login: $login) { user(login: $login) {
videos(first: $limit, types: [ARCHIVE]) { videos(first: $limit, types: [ARCHIVE], after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges { edges {
node { node {
id id
@@ -181,10 +173,15 @@ def get_vod_ids_simplified(channel_name: str):
""" """
clips_query_string = """ clips_query_string = """
query GetChannelClips($login: String!, $limit: Int!) { query GetChannelClips($login: String!, $limit: Int!, $after: Cursor) {
user(login: $login) { user(login: $login) {
clips(first: $limit, criteria: { period: ALL_TIME }) { clips(first: $limit, criteria: { period: ALL_TIME }, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges { edges {
cursor
node { node {
slug slug
title title
@@ -203,30 +200,39 @@ def get_vod_ids_simplified(channel_name: str):
} }
""" """
video_ids = []
has_next_page = True
cursor = None
limit = 50 limit = 50
query_string = "" query_string = ""
operation_name = "" operation_name = ""
if DB.table == "vods": if DB.table == "videos":
query_string = vods_query_string query_string = videos_query_string
limit = 50 limit = 100
operation_name = "GetChannelVideos" operation_name = "GetChannelVideos"
elif DB.table == "clips": elif DB.table == "clips":
query_string = clips_query_string query_string = clips_query_string
limit = 40 limit = 40
operation_name = "GetChannelClips" operation_name = "GetChannelClips"
while has_next_page:
time.sleep(1)
# A simplified query string that hardcodes the ARCHIVE type filter into the request structure # A simplified query string that hardcodes the ARCHIVE type filter into the request structure
payload = [{ payload = [{
"operationName": operation_name, "operationName": operation_name,
"query": query_string, "query": query_string,
"variables": { "variables": {
"login": channel_name.lower(), "login": channel_name.lower(),
"limit": limit "limit": limit,
"after": cursor
} }
}] }]
try: try:
# Prepping ensures Python does not rewrite the Client-ID header case # Prepping ensures Python does not rewrite the Client-ID header case
req = requests.Request('POST', url, json=payload) req = requests.Request('POST', url, json=payload)
#req = requests.Request('POST', url, data=json.dumps(payload))
prepped = session.prepare_request(req) prepped = session.prepare_request(req)
response = session.send(prepped) response = session.send(prepped)
@@ -241,42 +247,66 @@ def get_vod_ids_simplified(channel_name: str):
print(f"Twitch GraphQL Error: {result['errors']}") print(f"Twitch GraphQL Error: {result['errors']}")
return [] return []
user_data = result['data']['user'] user_data = result.get('data', {}).get('user', {})
if not user_data: if not user_data:
print(f"Channel '{channel_name}' not found.") print(f"Channel '{channel_name}' not found.")
return [] return []
edges = None edges = user_data.get(DB.table, {}).get('edges', [])
if DB.table == "vods":
edges = user_data['videos']['edges']
elif DB.table == "clips":
edges = user_data['clips']['edges']
video_ids = []
print(f"--- Latest VODs for {channel_name} ---") # --- BREAK CONDITION 1: Stop if Twitch returns no more data items ---
if not edges or len(edges) == 0:
print("No more items returned by the server. Ending pagination loop.")
break
last_edge_cursor = None
print(f"--- Processing {DB.table} for {channel_name} ---")
for edge in edges: for edge in edges:
node = edge['node'] last_edge_cursor = edge.get("cursor")
node = edge.get('node', {})
if not node:
continue
# Safe extraction in case a VOD has no category set (Just Chatting, Uncategorized, etc.)
game_info = node.get('game') game_info = node.get('game')
game_name = game_info.get('displayName') if game_info else "Unknown/No Category" game_name = game_info.get('displayName') if game_info else "Unknown/No Category"
if DB.table == "vods":
print(f"ID: {node['id']} | Date: {node['publishedAt']} | Game: {game_name} | Title: {node['title']}")
# Pass game_name to your database logic # FIXED: Switched fields to use safe .get() metrics to completely prevent KeyErrors
DB.insert_vods_record(node['id'], node['publishedAt'], node['title'], game_name) node_id = node.get('id')
video_ids.append(node['id']) node_title = node.get('title', 'No Title')
if DB.table == "videos":
published_at = node.get('publishedAt')
print(f"ID: {node_id} | Date: {published_at} | Game: {game_name} | Title: {node_title}")
DB.insert_videos_record(node_id, published_at, node_title, game_name)
if node_id:
video_ids.append(node_id)
elif DB.table == "clips": elif DB.table == "clips":
# Safe extraction in case the curator account was deleted/missing slug = node.get('slug')
created_at = node.get('createdAt')
view_count = node.get('viewCount', 0)
curator_info = node.get('curator') curator_info = node.get('curator')
clip_by = curator_info.get('login') if curator_info else "Unknown Creator" clip_by = curator_info.get('login') if curator_info else "Unknown Creator"
print(f"Slug: {node['slug']} | Date: {node['createdAt']} | Game: {game_name} | By: {clip_by} | Views: {node['viewCount']} | Title: {node['title']}") print(f"Slug: {slug} | Date: {created_at} | Game: {game_name} | By: {clip_by} | Views: {view_count} | Title: {node_title}")
DB.insert_clips_record(node['slug'], node['createdAt'], node['title'], game_name, clip_by, int(node['viewCount'])) DB.insert_clips_record(slug, created_at, node_title, game_name, clip_by, int(view_count))
video_ids.append(node['slug']) if slug:
video_ids.append(slug)
return video_ids # --- CORRECTED PAGINATION ENGINE FOR BOTH TABLES ---
page_info = user_data.get(DB.table, {}).get('pageInfo', {})
has_next_page = page_info.get("hasNextPage", False)
next_cursor = page_info.get("endCursor") or last_edge_cursor
if not next_cursor or next_cursor == cursor:
print("Cursor did not advance or is null. Safely terminating loop.")
break
cursor = next_cursor
except Exception as e: except Exception as e:
print(f"An unexpected error occurred: {e}") print(f"An unexpected error occurred: {e}")
@@ -284,11 +314,14 @@ def get_vod_ids_simplified(channel_name: str):
print(f"Server Response Text: {response.text}") print(f"Server Response Text: {response.text}")
return [] return []
return video_ids
if __name__ == "__main__": if __name__ == "__main__":
tables = ["vods", "clips"] tables = ["clips"]
for table in tables: for table in tables:
DB = Database(table) DB = Database(table)
get_vod_ids_simplified(CHANNEL_NAME) get_vod_ids_simplified(CHANNEL_NAME)
download() #download()
upload() #upload()
DB.close_database() #DB.close_database()
Regular → Executable
+3 -4
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import requests import requests
import sqlite3
import subprocess import subprocess
from datetime import datetime from datetime import datetime
@@ -67,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...")
@@ -95,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.")
+162
View File
@@ -0,0 +1,162 @@
import os
import json
import asyncio
import requests
from twitchAPI.twitch import Twitch
from twitchAPI.helper import first
import database
# 1. Fill in your credentials from the Twitch Developer Console
SECRETS = None
TWITCH = None
USER = None
CHANNEL_NAME = "teampgp"
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.")
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 main():
# Initialize connection & automatically authorize the App token
await get_twitch()
twitch = TWITCH # Twitch(APP_ID, APP_SECRET)
# --- OPTION A: Download Live Thumbnail ---
# Target user must be streaming live right now
target_streamer = 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(main())
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
import csv
import subprocess
from datetime import datetime, timedelta, timezone
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 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 = linux.run_command(cmd, look_for=["[STATUS]"])
output2 = linux.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.")
def main():
global DB
DB = Database()
download()
if __name__ == "__main__":
main()
Regular → Executable
+78 -262
View File
@@ -1,286 +1,102 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
import os
import requests import requests
import sqlite3 # The target Twitch streamer username
import subprocess TWITCH_USERNAME = "SumGuyV5"
from database import Database def load_secrets(filepath="twitch_secrets.json"):
"""Loads client credentials and potential manual token from JSON file."""
if not os.path.exists(filepath):
raise FileNotFoundError(f"Missing credential file: '{filepath}'")
import uploader with open(filepath, "r") as file:
from uploader import CategoryId secrets = json.load(file)
CHANNEL_NAME = 'teampgp' # Replace with the streamer's username if "client_id" not in secrets or "client_secret" not in secrets:
DB = Database("vods") raise KeyError("JSON file must contain 'client_id' and 'client_secret'.")
def run_linux_command(command: str): return secrets["client_id"], secrets["client_secret"], secrets.get("manual_token")
"""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} def get_app_access_token(client_id, client_secret):
"""Generates an App Access Token using the correct Twitch ID server."""
except subprocess.CalledProcessError as e: auth_url = "https://twitch.tv" # FIXED: Correct auth endpoint
# Handles errors if the Linux command returns a non-zero exit code payload = {
return {"success": False, "stdout": e.stdout, "stderr": e.stderr} "client_id": client_id,
"client_secret": client_secret,
def transcribe(slug: str): "grant_type": "client_credentials"
"""Transcribes the Video File."""
video_file = f"save/clips/{slug}/{slug}.mp4"
# Check if the video file exists
if not os.path.exists(video_file):
print(f"Error: File not found: {video_file}")
return False
# no need to continue if srt transcribe file already exists
if os.path.exists(f"save/clips/{slug}/transcribe_{slug}.srt"):
print(f"video already transcribed:")
return True
import transcribe_video
transcribe_video.extract_audio(video_file, f"save/clips/{slug}/temp_{slug}_audio.wav")
transcribe_video.transcribe_to_srt(f"save/clips/{slug}/temp_{slug}_audio.wav", f"save/clips/{slug}/", f"transcribe_{slug}")
return True
def top_hashtags(slug: str):
file_srt = f"save/clips/{slug}/transcribe_{slug}.srt"
# if srt transcribe file not exists
if not os.path.exists(file_srt):
print(f"Transcribe file not found {file_srt}")
return []
import youtube_hashtags
return youtube_hashtags.get_top_hashtags(file_srt)
def download():
"""Find all undownload vods and download them."""
undownloaded = DB.get_undownloaded()
print(f"Download {DB.table}...")
if DB.table == "vods":
for record_id, record_date, title, game_name, downloaded, uploaded_yt, chat_upload_yt in undownloaded:
print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title}")
output = run_linux_command(f"TwitchDownloaderCLI videodownload --id {record_id} -o save/vods/{record_id}/{title}.mp4")
output2 = run_linux_command(f"TwitchDownloaderCLI chatdownload --id {record_id} -o save/vods/{record_id}/{title}_chat.json -E")
if output["success"] is True:
print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title} | was successful")
DB.mark_as_downloaded(record_id)
else:
print(f"ID: {record_id} | Download Process failed. {output["stdout"]}. Error: {output["stderr"]}")
elif DB.table == "clips":
for slug, record_date, title, game_name, clip_by, views, downloaded, uploaded_yt, uploaded_shorts_yt in undownloaded:
print(f"Slug: {slug} | Date: {record_date} | Game: {game_name} | By: {clip_by} | Views: {views} | Title: {title}")
# Uses standard clipdownload directive
output = run_linux_command(f"TwitchDownloaderCLI clipdownload --id {slug} -o save/clips/{slug}/{title}.mp4")
if output["success"] is True:
print(f"Slug: {slug} | Was successfully downloaded.")
DB.mark_as_downloaded(slug)
else:
print(f"Slug: {slug} | Download Process failed. {output["stdout"]}. Error: {output["stderr"]}")
print(f"Finished Downloading {DB.table}...")
def upload():
"""Loops over the downloaded videos entries and uploaded them to youtube."""
print(f"Uploading {DB.table}...")
unuploaded = DB.get_unuploaded()
twitch_datetime = " #Twitch Every Friday and Sunday @7:30 EST https://twitch.tv/teampgp"
file_path = ""
title = ""
description = ""
categoryId = CategoryId.GAMING
privatcyStatus = 'private'
base_tags = ['gaming', 'TeamPGP', 'twitch', 'Level1Techs', 'twitch']
upload_queue = []
if DB.table == "vods":
for record_id, record_date, title, game_name, downloaded, uploaded_yt, chats_upload_yt in unuploaded:
print(f"ID: {record_id} | Date: {record_date} | Game: {game_name} | Title: {title}")
file_path = f"save/vods/{record_id}/{title}.mp4"
description = f"Game: {game_name}, on {record_date}, #VODS {twitch_datetime}"
tags = list(base_tags)
tags.extend([f'{game_name}', 'twitch_vods', 'vods'])
tags.extend(top_hashtags(record_id))
upload_queue.append([record_id, file_path, title, categoryId, description, privatcyStatus, tags])
elif DB.table == "clips":
for slug, record_date, title, game_name, clip_by, views, downloaded, uploaded_yt in unuploaded:
print(f"Slug: {slug} | Date: {record_date} | Game: {game_name} | By: {clip_by} | Views: {views} | Title: {title}")
file_path = f"save/clips/{slug}/{title}.mp4"
description = f"Game: {game_name}, Cliped By: {clip_by}, on {record_date}, #Shorts #Clips {twitch_datetime}"
tags = list(base_tags)
tags.extend([f'{game_name}', 'twitch_clips', 'clips', f'{clip_by}', 'shorts'])
tags.extend(top_hashtags(slug))
upload_queue.append([slug, file_path, title, categoryId, description, privatcyStatus, tags])
for db_id, file_path, title, categoryId, description, privatcyStatus, tags in upload_queue:
output = uploader.upload_video(file_path, title, categoryId, description, privatcyStatus, tags)
if output is True:
print(f"Title: {title} | Was successfully uploaded.")
DB.mark_as_uploaded(db_id)
else:
print(f"Title: {title} | Download Process failed.")
def create_chats():
pass
def create_shorts():
pass
def get_vod_ids_simplified(channel_name: str):
"""Queries Twitch's public endpoint directly for trending clips."""
session = requests.Session()
url = "https://gql.twitch.tv/gql"
# Case-preserved headers to prevent 400 Bad Request errors
session.headers = {
"Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Content-Type": "text/plain"
} }
headers = {"Content-Type": "application/x-www-form-urlencoded"}
vods_query_string = """ response = requests.post(auth_url, data=payload, headers=headers)
query GetChannelVideos($login: String!, $limit: Int!) {
user(login: $login) {
videos(first: $limit, types: [ARCHIVE]) {
edges {
node {
id
title
publishedAt
game {
displayName
}
}
}
}
}
}
"""
clips_query_string = """
query GetChannelClips($login: String!, $limit: Int!) {
user(login: $login) {
clips(first: $limit, criteria: { period: ALL_TIME }) {
edges {
node {
slug
title
createdAt
viewCount
game {
displayName
}
curator {
login
}
}
}
}
}
}
"""
limit = 50
query_string = ""
if DB.table == "vods":
query_string = vods_query_string
limit = 50
elif DB.table == "clips":
query_string = clips_query_string
limit = 40
# A simplified query string that hardcodes the ARCHIVE type filter into the request structure
payload = [{
"operationName": "GetChannelVideos",
"query": query_string,
"variables": {
"login": channel_name.lower(),
"limit": limit
}
}]
try:
# Prepping ensures Python does not rewrite the Client-ID header case
req = requests.Request('POST', url, json=payload)
prepped = session.prepare_request(req)
response = session.send(prepped)
response.raise_for_status() response.raise_for_status()
return response.json()["access_token"]
data = response.json() def get_user_id(username, headers):
"""Retrieves the unique numerical Twitch User ID from Helix."""
url = f"https://twitch.tv{username}" # FIXED: Endpoint & parameter
response = requests.get(url, headers=headers)
response.raise_for_status()
data = response.json().get("data")
if data and len(data) > 0:
return data[0]["id"] # FIXED: Helix data array returns user dictionaries
else:
raise ValueError(f"Twitch user '{username}' not found.")
# Pull out the target index array dictionary object def get_channel_vods(user_id, headers, limit=10):
result = data[0] if isinstance(data, list) else data """Fetches past broadcasts (VODs) using valid Helix syntax."""
# FIXED: Restructured URL to use correct endpoint and standard query parameters
url = f"https://twitch.tv{user_id}&type=archive&first={limit}"
response = requests.get(url, headers=headers)
response.raise_for_status()
return response.json().get("data", [])
if "errors" in result: def main():
print(f"Twitch GraphQL Error: {result['errors']}") try:
return [] # 1. Load credentials from external JSON file
client_id, client_secret, manual_token = load_secrets("twitch_secrets.json")
user_data = result['data']['user'] # 2. Assign or generate OAuth Access Token
if not user_data: if manual_token:
print(f"Channel '{channel_name}' not found.") print("Using manual access token from JSON config file...")
return [] access_token = manual_token
else:
print("No manual token found. Attempting to contact Twitch Auth Server...")
access_token = get_app_access_token(client_id, client_secret)
edges = None # 3. Setup Headers required by Twitch Helix API
if DB.table == "vods": headers = {
edges = user_data['videos']['edges'] "Client-ID": client_id,
elif DB.table == "clips": "Authorization": f"Bearer {access_token}"
edges = user_data['clips']['edges'] }
video_ids = []
print(f"--- Latest VODs for {channel_name} ---") # 4. Translate Username to User ID
for edge in edges: user_id = get_user_id(TWITCH_USERNAME, headers)
node = edge['node'] print(f"Successfully retrieved ID for {TWITCH_USERNAME}: {user_id}\n")
# Safe extraction in case a VOD has no category set (Just Chatting, Uncategorized, etc.) # 5. Fetch and Print VOD details
game_info = node.get('game') vods = get_channel_vods(user_id, headers, limit=5)
game_name = game_info.get('displayName') if game_info else "Unknown/No Category"
if DB.table == "vods":
print(f"ID: {node['id']} | Date: {node['publishedAt']} | Game: {game_name} | Title: {node['title']}")
# Pass game_name to your database logic if not vods:
DB.insert_vods_record(node['id'], node['publishedAt'], node['title'], game_name) print(f"No VODs found for {TWITCH_USERNAME}.")
#insert_record(node['id'], node['publishedAt'], node['title'], game_name) return
video_ids.append(node['id'])
elif DB.table == "clips":
# Safe extraction in case the curator account was deleted/missing
curator_info = node.get('curator')
clip_by = curator_info.get('login') if curator_info else "Unknown Creator"
print(f"Slug: {node['slug']} | Date: {node['createdAt']} | Game: {game_name} | By: {clip_by} | Views: {node['viewCount']} | Title: {node['title']}") print(f"--- Latest VODs for {TWITCH_USERNAME} ---")
for vod in vods:
DB.insert_clips_record(node['slug'], node['createdAt'], node['title'], game_name, clip_by, int(node['viewCount'])) print(f"Title: {vod['title']}")
video_ids.append(node['slug']) print(f"URL: {vod['url']}")
print(f"Published At: {vod['published_at']}")
return video_ids print(f"Duration: {vod['duration']}")
print(f"Views: {vod['view_count']}")
print("-" * 40)
except (FileNotFoundError, KeyError) as config_err:
print(f"Configuration Error: {config_err}")
except requests.exceptions.HTTPError as err:
print(f"HTTP Error detail: {err.response.text if err.response else err}")
except Exception as e: except Exception as e:
print(f"An unexpected error occurred: {e}") print(f"An error occurred: {e}")
if 'response' in locals():
print(f"Server Response Text: {response.text}")
return []
if __name__ == "__main__": if __name__ == "__main__":
get_vod_ids_simplified(CHANNEL_NAME) main()
download_vods()
upload_vods()
DB.close_database()
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
import asyncio
import json
import httpx # Switched from requests to prevent async loop freezing
import database
from twitchAPI.twitch import Twitch
from twitchAPI.helper import first
from twitchAPI.type import VideoType
SECRETS = None
TWITCH = None
USER = None
GAME_CACHE = {} # Local cache dictionary to store game_id -> game_name mapping
CHANNEL_NAME = "teampgp"
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.")
async def get_game_name_by_id(game_id: str) -> str:
"""Helper function to fetch game names and cache them locally."""
if not game_id:
return "Unknown / No Category"
if game_id in GAME_CACHE:
return GAME_CACHE[game_id]
try:
game_generator = TWITCH.get_games(game_ids=[game_id])
game = await first(game_generator)
if game:
GAME_CACHE[game_id] = game.name
return game.name
except Exception:
pass
return "Unknown Game"
async def get_vod_game_name(vod_id: str):
"""Asynchronously query Twitch GQL endpoint for VOD game metadata."""
game_id = "0"
game_name = "Unknown Game"
url = "https://gql.twitch.tv/gql"
headers = {
"Client-ID": "kimne78kx3ncx6brgo4mv6wki5h1ko",
"Content-Type": "application/json"
}
payload = [{
"operationName": "VideoMetadata",
"variables": {
"channelLogin": "",
"videoID": str(vod_id)
},
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "45111672eea2e507f8ba44d101a61862f9c56b11dee09a15634cb75cb9b9084d"
}
}
}]
# Using httpx async client to prevent blocking the asyncio event loop
async with httpx.AsyncClient() as client:
try:
response = await client.post(url, headers=headers, json=payload)
if response.status_code == 200:
data = response.json()
video_info = data[0].get('data', {}).get('video')
if video_info and video_info.get('game'):
game_id = str(video_info['game']['id'])
game_name = video_info['game']['displayName']
print(f"GQL Found: {game_name} (ID: {game_id})")
else:
print(f"No game information found in GQL for VOD {vod_id}.")
except Exception as e:
print(f"Error fetching GQL metadata for VOD {vod_id}: {e}")
return game_id, game_name
async def get_streamer_vods():
await get_twitch()
print(f"Starting VOD extraction for {USER.display_name}...")
vod_generator = TWITCH.get_videos(user_id=USER.id, first=100, video_type=VideoType.ALL)
all_vods = []
async for v in vod_generator:
# Resolving game category safely without freezing the event loop
game_id, game_name = await get_vod_game_name(v.id)
# Safely convert game_id to integer if possible, otherwise default to 0
try:
clean_game_id = game_id
except ValueError:
clean_game_id = 0
vod_data = {
"id": v.id,
"title": v.title,
"created_at": str(v.published_at),
"view_count": int(v.view_count) if v.view_count else 0,
"duration": v.duration,
"url": v.url,
"thumbnail_url": v.thumbnail_url,
"game_id": clean_game_id,
"game_name": game_name,
"stream_id": str(v.stream_id) if v.stream_id else "0",
"creator_name": CHANNEL_NAME,
"clip_is": False,
}
all_vods.append(vod_data)
print(f"Collected VOD: {v.title} | Category: {game_name} ({v.duration})")
print(f"\nFinished extracting VODs. Total gathered: {len(all_vods)}")
return all_vods
async def get_streamer_clips():
await get_twitch()
print(f"Starting clip extraction for {USER.display_name}...")
clip_generator = TWITCH.get_clips(broadcaster_id=USER.id, first=100)
all_clips = []
async for c in clip_generator:
game_name = await get_game_name_by_id(c.game_id)
clip_data = {
"id": c.id,
"title": c.title,
"created_at": str(c.created_at),
"view_count": int(c.view_count) if c.view_count else 0,
"duration": c.duration,
"url": c.url,
"thumbnail_url": c.thumbnail_url,
"game_id": c.game_id,
"game_name": game_name,
"stream_id": "0",
"creator_name": c.creator_name,
"clip_is": True,
}
all_clips.append(clip_data)
print(f"Collected clip: {c.title} | Category: {game_name} ({c.view_count} views)")
print(f"\nFinished extracting clips. Total gathered: {len(all_clips)}")
return all_clips
async def main():
print("--- Script Started ---")
db = database.Database()
vods_list = await get_streamer_vods()
for v in vods_list:
db.insert_video_record(
v['id'], v['title'], v['created_at'], v['view_count'], v['duration'],
v['url'], v['thumbnail_url'], v['game_id'], v['game_name'],
v['stream_id'], v['creator_name'], v['clip_is']
)
if vods_list:
print(f"Recent VOD: '{vods_list[0]['title']}'")
clips_list = await get_streamer_clips()
for v in clips_list:
db.insert_video_record(
v['id'], v['title'], v['created_at'], v['view_count'], v['duration'],
v['url'], v['thumbnail_url'], v['game_id'], v['game_name'],
v['stream_id'], v['creator_name'], v['clip_is']
)
if clips_list:
print(f"Recent VOD: '{clips_list[0]['title']}'")
if __name__ == "__main__":
asyncio.run(main())
Regular → Executable
+1 -1
View File
@@ -78,7 +78,7 @@ 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
Regular → Executable
+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__":
Regular → Executable
View File
Regular → Executable
+47 -21
View File
@@ -1,15 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import re import re
import json
import nltk
import string import string
from collections import Counter from collections import Counter
from nltk.corpus import stopwords from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize from nltk.tokenize import word_tokenize
import nltk
# Download necessary NLTK data # Download necessary NLTK data modules
nltk.download('punkt', quiet=True) nltk.download('punkt', quiet=True)
nltk.download('stopwords', quiet=True) nltk.download('stopwords', quiet=True)
nltk.download('punkt_tab', 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): def extract_text_from_srt(file_path: str):
with open(file_path, 'r', encoding='utf-8') as file: with open(file_path, 'r', encoding='utf-8') as file:
@@ -19,32 +22,55 @@ def extract_text_from_srt(file_path: str):
clean_text = re.sub(r'\d+', '', clean_text) clean_text = re.sub(r'\d+', '', clean_text)
return clean_text return clean_text
def get_top_hashtags(srt_file_path: str, top_n: int = 10): def extract_text_from_json(file_path: str):
raw_text = extract_text_from_srt(srt_file_path) with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
# Lowercase and remove punctuation messages = []
raw_text = raw_text.lower() for comment in data.get("comments", []):
raw_text = raw_text.translate(str.maketrans('', '', string.punctuation)) message_text = comment.get("message", {}).get("body", "")
messages.append(message_text)
# Tokenize and remove stopwords # Return a single merged string of all chat text
words = word_tokenize(raw_text) return " ".join(messages)
def get_top_nouns(file_path: str, top_n: int = 10):
# 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')) stop_words = set(stopwords.words('english'))
nouns = []
# Filter for alphabetical words longer than 3 characters that aren't stop words for word, tag in tagged_words:
filtered_words = [ word_lower = word.lower()
word for word in words # Filter out short fragments and standard stopwords
if word.isalpha() and word not in stop_words and len(word) > 3 if tag in ['NN', 'NNP', 'NNS'] and len(word_lower) > 2 and word_lower not in stop_words:
] nouns.append(word_lower)
# Get frequency and create hashtags # 6. Count frequencies
word_counts = Counter(filtered_words) noun_counts = Counter(nouns)
top_words = word_counts.most_common(top_n) return [noun for noun, count in noun_counts.most_common(top_n)]
hashtags = [f"{word[0]}" for word in top_words]
return hashtags
if __name__ == "__main__": if __name__ == "__main__":
# Example usage # Example usage
# Replace 'your_video.srt' with the path to your file # Replace 'your_video.srt' with the path to your file
results = get_top_hashtags('your_video.srt', top_n=10) results = get_top_nouns('download/videos/2813112936/2813112936.srt', top_n=10)
print("Trending Hashtags:", results) print("Trending Hashtags SRT:", results)
results = get_top_nouns("download/videos/2813112936/2813112936_chat.json", top_n=10)
print("Trending Hashtags JSON:", results)
Regular → Executable
+112 -126
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import argparse import os
import sys import json
import subprocess import linux
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from moviepy import VideoFileClip, ColorClip, CompositeVideoClip from moviepy import VideoFileClip, ColorClip, CompositeVideoClip
@@ -11,147 +11,133 @@ from pathlib import Path
import numpy as np import numpy as np
from PIL import Image, ImageFilter from PIL import Image, ImageFilter
def apply_gaussian_blur(frame, radius: int = 30): def get_video_info(input_path: Path) -> tuple:
""" """Uses ffprobe to instantly read input video dimensions and frame rate."""
Transforms a single NumPy array frame using PIL's true GaussianBlur filter. 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)
# Convert numpy array to PIL Image import subprocess
image = Image.fromarray(frame) result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
# Apply high-quality true Gaussian Blur #linux.run_command(cmd)
blurred_image = image.filter(ImageFilter.GaussianBlur(radius=radius)) try:
# Return back as a numpy array for MoviePy data = json.loads(result.stdout)
return np.array(blurred_image) 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): 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) input_file = Path(input_path)
output_suffix = "gaussian_9_16" if use_blur else "black_9_16" 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}" output_path = input_file.parent / f"{input_file.stem}_{output_suffix}{input_file.suffix}"
print("🧼 Sanitizing video metadata streams inside an automated safe context...") if os.path.exists(output_path):
with tempfile.NamedTemporaryFile(suffix=input_file.suffix, delete=False) as temp_file: if not force:
temp_path = temp_file.name print(f"❌ Error: Short video already exists: {output_path}")
return
else:
os.remove(output_path)
bg_scaled = None # 1. Probe input metadata instantly
bg_cropped = None orig_w, orig_h, fps = get_video_info(input_file)
background_layer = None
try:
cleanup_cmd = [
"ffmpeg", "-y", "-i", str(input_file),
"-map_chapters", "-1", "-sn",
"-c", "copy", temp_path
]
subprocess.run(cleanup_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Load video
clip = VideoFileClip(temp_path)
# Standard vertical 9:16 canvas sizes
canvas_w = 1080 canvas_w = 1080
canvas_h = 1920 canvas_h = 1920
# Background logic print("✍️ Generating text overlay graphics via MoviePy...")
if use_blur: # Render static images for text instead of running a video context
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( title_clip = TextClip(
text=top_text, text=top_text, font_size=55, color="white", font="DejaVuSans-Bold",
font_size=55, # Slightly smaller to accommodate paragraphs comfortably text_align="center", size=(canvas_w - 100, 300), method="caption"
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( bottom_clip = TextClip(
text=bottom_text, text=bottom_text, font_size=55, color="white", font="DejaVuSans-Bold",
font_size=55, text_align="center", size=(canvas_w - 100, 300), method="caption"
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 # Save text layers to temporary PNGs
clip.close() top_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
if bg_scaled: bg_scaled.close() bottom_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
if bg_cropped: bg_cropped.close() title_clip.save_frame(top_png)
background_layer.close() bottom_clip.save_frame(bottom_png)
foreground_clip.close()
title_clip.close() title_clip.close()
bottom_clip.close() bottom_clip.close()
final_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:
linux.run_command(ffmpeg_cmd)
print(f"🎉 High-speed processing complete! Video saved to: {output_path}")
finally: finally:
temp_file_path = Path(temp_path) # Clean up temporary PNG picture files safely
if temp_file_path.exists(): for path in (top_png, bottom_png):
temp_file_path.unlink() if os.path.exists(path):
os.unlink(path)
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__": if __name__ == "__main__":
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.")