Compare commits

..
2 Commits
55 changed files with 779 additions and 4067 deletions

No files matched your search

-6
View File
@@ -6,9 +6,3 @@ save
__pycache__
twitch_secrets.json
.vscode/settings.json
download
output.log
*.mp4
database copy.db
tiktok_secrets.json
cookies.txt
View File
File renamed without changes.
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any
class Database:
def __init__(self):
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("database.db").is_file()
self.conn = sqlite3.connect("database.db")
self.cursor = self.conn.cursor()
if not file_exists:
self.create_database()
def __del__(self):
# Destructors are unpredictable in Python; explicitly close when done instead
try:
self.close_database()
except:
pass
def create_database(self):
"""Creates a table structured explicitly for Twitch videos properties."""
self.cursor.execute("""
CREATE TABLE IF NOT EXISTS twitch_videos (
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 INTEGER 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
)
""")
self.conn.commit()
def close_database(self):
"""Commit before we close."""
if self.conn:
self.conn.commit()
self.conn.close()
def __mark_as(self, record_id: str, set_sql: str):
self.cursor.execute(
f"UPDATE twitch_videos SET {set_sql} = 1 WHERE id = ?",
(record_id,))
self.conn.commit()
def mark_as_uploaded_shorts(self, record_id: str):
"""Flags a specific row record to uploaded_yt_shorts (1)."""
self.__mark_as(record_id, "uploaded_yt_shorts")
def mark_as_uploaded_chats(self, record_id: str):
"""Flags a specific row record to uploaded_yt_chats (1)."""
self.__mark_as(record_id, "uploaded_yt_chats")
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")
def mark_as_downloaded(self, record_id: str):
"""Flags a specific row record to downloaded (1)."""
self.__mark_as(record_id, "downloaded")
def __get_unuploaded(self, set_sql: str) -> list[Any]:
"""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")
return self.cursor.fetchall()
def get_unuploaded_shorts(self) -> list[Any]:
"""Retrieve all rows that were download but not uploaded_yt_shorts"""
return self.__get_unuploaded("uploaded_yt_shorts")
def get_unuploaded_chats(self) -> list[Any]:
"""Retrieve all rows that were download but not uploaded_yt_chats"""
return self.__get_unuploaded("uploaded_yt_chats")
def get_unuploaded(self) -> list[Any]:
"""Retrieve all rows that were download but not uploaded_yt"""
return self.__get_unuploaded("uploaded_yt")
def get_undownloaded(self) -> list[Any]:
"""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 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:
dt_obj = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ")
clean_datetime = dt_obj.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
clean_datetime = created_at
# Explicitly defining columns removes the security risk and column-count bug
query = """
INSERT OR IGNORE INTO twitch_videos (
id, title, created_at, view_count, duration, url, thumbnail_url,
game_id, game_name, stream_id, creator_name, clip_is
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
values = (
id, title, clean_datetime, view_count, duration, url, thumbnail_url,
game_id, game_name, stream_id, creator_name, clip_is
)
try:
self.cursor.execute(query, values)
self.conn.commit()
except Exception as e:
# Prevent silent failures if the database connection drops
print(f"Database insertion failed: {e}")
self.conn.rollback()
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any
class Database:
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.data = "id, name, date_shorts, date_videos, date_videos_chat"
file_exists = Path(db_path).is_file()
self.conn = sqlite3.connect(db_path)
self.cursor = self.conn.cursor()
if not file_exists:
self.create_database()
def __exit__(self):
# Destructors are unpredictable in Python; explicitly close when done instead
try:
self.close_database()
except:
pass
def create_database(self):
"""Creates a table structured explicitly for Twitch videos properties."""
self.cursor.execute("""
CREATE TABLE IF NOT EXISTS twitch_videos (
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
)
""")
self.cursor.execute("""
CREATE TABLE IF NOT EXISTS data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
date_shorts TEXT NOT NULL,
date_videos TEXT NOT NULL,
date_videos_chat TEXT NOT NULL
)
""")
self.conn.commit()
def close_database(self):
"""Commit before we close."""
if self.conn:
self.conn.commit()
self.conn.close()
def __mark_as(self, record_id: str, set_row: str, mark: str = "1" ):
self.cursor.execute(
f"UPDATE twitch_videos SET {set_row} = ? WHERE id = ?",
(mark, record_id))
self.conn.commit()
def mark_as_uploaded_shorts(self, record_id: str):
"""Flags a specific row record to uploaded_yt_shorts (1)."""
self.__mark_as(record_id, "uploaded_yt_shorts")
def mark_as_uploaded_chats(self, record_id: str):
"""Flags a specific row record to uploaded_yt_chats (1)."""
self.__mark_as(record_id, "uploaded_yt_chats")
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")
def mark_as_downloaded(self, record_id: str):
"""Flags a specific row record to downloaded (1)."""
self.__mark_as(record_id, "downloaded")
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"""
self.cursor.execute(f"SELECT {self.columns} FROM twitch_videos WHERE downloaded = 1 AND {set_row} = 0 {also}")
return self.cursor.fetchall()
def get_unuploaded_shorts(self) -> list[Any]:
"""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_chats(self) -> list[Any]:
"""Retrieve all rows that were download but not uploaded_yt_chats"""
return self.__get_unuploaded("uploaded_yt_chats", "AND clip_is = 0")
def get_unuploaded(self) -> list[Any]:
"""Retrieve all rows that were download but not uploaded_yt"""
return self.__get_unuploaded("uploaded_yt")
def get_undownloaded(self) -> list[Any]:
"""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:
dt_obj = datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ")
clean_datetime = dt_obj.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
clean_datetime = created_at
# Explicitly defining columns removes the security risk and column-count bug
query = """
INSERT INTO twitch_videos (
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
)
try:
self.cursor.execute(query, values)
self.conn.commit()
except Exception as e:
# Prevent silent failures if the database connection drops
print(f"❌ Database insertion failed: {e}")
self.conn.rollback()
def __update_date(self, record_id: str, set_row: str, date: str):
"""Updates the Datetime for timezone fix"""
self.cursor.execute(
f"UPDATE data SET {set_row} = ? WHERE id = ?",
(date, record_id))
self.conn.commit()
def update_date_shorts(self, record_id: str, date_shorts: str):
self.__update_date(record_id, "date_shorts", date_shorts)
def get_data(self) -> list[Any]:
self.cursor.execute(f"SELECT {self.data} FROM data")
return self.cursor.fetchall()
def insert_data_record(self, name: str, date_shorts: str, date_videos: str, date_videos_chat: str):
try:
dt_obj = datetime.strptime(date_shorts, "%Y-%m-%dT%H:%M:%SZ")
clean_date_shorts = dt_obj.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
clean_date_shorts = date_shorts
try:
dt_obj = datetime.strptime(date_videos, "%Y-%m-%dT%H:%M:%SZ")
clean_date_videos = dt_obj.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
clean_date_videos = date_videos
try:
dt_obj = datetime.strptime(date_videos_chat, "%Y-%m-%dT%H:%M:%SZ")
clean_date_videos_chat = dt_obj.strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
clean_date_videos_chat = date_videos_chat
# Explicitly defining columns removes the security risk and column-count bug
query = """
INSERT INTO data (
name, date_shorts, date_videos, date_videos_chat
) VALUES (?, ?, ?, ?)
"""
values = (
name, clean_date_shorts, clean_date_videos, clean_date_videos_chat
)
try:
self.cursor.execute(query, values)
self.conn.commit()
except Exception as e:
# Prevent silent failures if the database connection drops
print(f"❌ Database insertion failed: {e}")
self.conn.rollback()
+43 -16
View File
@@ -1,20 +1,45 @@
#!/usr/bin/env python3
from typing import Any
import json
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
import package.youtube.config as config
def load_credentials():
"""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: build) -> Any:
def get_my_uploads_playlist_id(youtube):
"""Retrieves the system upload playlist ID for the authenticated user's channel."""
try:
# mine=True automatically references the authorized account
request = youtube.channels().list(part="contentDetails", mine=True)
response = request.execute()
if "items" in response and len(response['items']) > 0:
return response['items'][0]['contentDetails']['relatedPlaylists']['uploads']
if "items" in response and len(response["items"]) > 0:
return response["items"][0]["contentDetails"]["relatedPlaylists"]["uploads"]
else:
print("No channel found for these credentials.")
return None
@@ -22,7 +47,7 @@ def get_my_uploads_playlist_id(youtube: build) -> Any:
print(f"API Error retrieving channel details: {str(e)}")
return None
def scan_channel_videos_for_tag(youtube: build, uploads_playlist_id: str, target_tag: str) -> list:
def scan_channel_videos_for_tag(youtube, uploads_playlist_id: str, target_tag: str):
"""
Loops through all channel video uploads and filters those possessing the target tag.
"""
@@ -45,7 +70,7 @@ def scan_channel_videos_for_tag(youtube: build, uploads_playlist_id: str, target
playlist_response = playlist_request.execute()
video_ids_batch = [
item['snippet']['resourceId']['videoId']
item["snippet"]["resourceId"]["videoId"]
for item in playlist_response.get("items", [])
]
@@ -62,10 +87,10 @@ def scan_channel_videos_for_tag(youtube: build, uploads_playlist_id: str, target
video_response = video_request.execute()
for video in video_response.get("items", []):
title = video['snippet']['title']
video_id = video['id']
title = video["snippet"]["title"]
video_id = video["id"]
# Tags are optional fields on YouTube; default to an empty list if absent
tags = video['snippet'].get("tags", [])
tags = video["snippet"].get("tags", [])
# Normalize tags to lowercase for clean matching evaluation
tags_lower = [tag.lower() for tag in tags]
@@ -95,10 +120,12 @@ def scan_channel_videos_for_tag(youtube: build, uploads_playlist_id: str, target
return matched_videos
def find_tags():
youtube = config.get_youtube_service()
def main():
credentials = load_credentials()
if not credentials:
return
youtube = build('youtube', 'v3', credentials=credentials)
# 1. Fetch your dynamic uploads playlist pointer
uploads_id = get_my_uploads_playlist_id(youtube)
@@ -117,4 +144,4 @@ def find_tags():
print(f"{index}. {item['title']} -> https://youtu.be{item['id']}")
if __name__ == "__main__":
find_tags()
main()
-12
View File
@@ -1,12 +0,0 @@
-- database: ./database.db
SELECT * FROM twitch_videos WHERE clip_is == "1";
UPDATE twitch_videos SET uploaded_yt_shorts = 0 WHERE uploaded_yt_shorts == "1";
SELECT * FROM twitch_videos WHERE title == "Oh Dear";
SELECT * FROM twitch_videos WHERE title = 'Kreestuh FINALLY hits her "Ryan Dark Story" limit. Jesus Big K it''s about time!';
SELECT * FROM twitch_videos WHERE title = "Bolt-Action Bureaucracy";
SELECT * FROM twitch_videos WHERE title = "Just after going through a mass starvation...";
SELECT * FROM twitch_videos WHERE title = "Semi-Automatic Sanctimony";
SELECT * FROM twitch_videos WHERE id = "2848295283";
SELECT * FROM twitch_videos WHERE game_name = "Darkwood";
SELECT * FROM data;
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
import os
import json
import sys
# Finds the absolute path to 'project_directory' and adds it to Python's search path
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
sys.path.append(PROJECT_ROOT)
from package.config import DB as DB
from package.database import Chapter
def get_chapters(json_path):
"""Loads the JSON file and returns all chapters."""
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# The chapters are under video -> chapters
video = data.get('video', {})
chapters = video.get('chapters', [])
if not chapters:
print("No chapters found.")
return []
print(f"Found {len(chapters)} chapter(s) in video '{video.get('title')}':\n")
for i, chapter in enumerate(chapters, 1):
start_sec = chapter.get('startMilliseconds', 0) / 1000
length_sec = chapter.get('lengthMilliseconds', 0) / 1000
print(f"{i}. {chapter.get('description', 'No Title')}")
print(f" - Type: {chapter.get('type')}")
print(f" - Start: {start_sec}s ({chapter.get('startMilliseconds')}ms)")
print(f" - Length: {length_sec}s ({chapter.get('lengthMilliseconds')}ms)")
print(f" - Game: {chapter.get('gameDisplayName')} (ID: {chapter.get('gameId')})")
print()
return chapters
def main():
downloads = DB.get_downloaded()
for download in downloads:
json_path = f"download/clips/{download.id}/{download.id}_chat.json" if download.clip_is else f"download/videos/{download.id}/{download.id}_chat.json"
if os.path.exists(json_path):
chapters = get_chapters(json_path)
for chapter in chapters:
chapter_record = Chapter(
twitch_video_id=download.id,
start_milliseconds=chapter.get('startMilliseconds'),
length_milliseconds=chapter.get('lengthMilliseconds'),
description=chapter.get('description'),
type=chapter.get('type'),
game_display_name=chapter.get('gameDisplayName'),
game_id=chapter.get('gameId')
)
DB.insert(chapter_record)
else:
print(f"JSON file not found for video {download.id}: {json_path}")
if __name__ == "__main__":
main()
# Usage: python get_chapters.py your_file.json
#path = sys.argv[1] if len(sys.argv) > 1 else "/home/richard/python_scripts/download/videos/2873107835/2873107835_chat.json"
#try:
# get_chapters(path)
#except FileNotFoundError:
# print(f"Error: File not found -> {path}")
#except json.JSONDecodeError:
# print(f"Error: File is not valid JSON -> {path}")
-63
View File
@@ -1,63 +0,0 @@
#!/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.Repository()
fix_time()
if __name__ == "__main__":
main()
-52
View File
@@ -1,52 +0,0 @@
#!/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.Repository()
fix_download()
if __name__ == "__main__":
main()
-115
View File
@@ -1,115 +0,0 @@
#!/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.Repository()
# 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
@@ -1,67 +0,0 @@
#!/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()
-45
View File
@@ -1,45 +0,0 @@
import sqlite3
DB_PATH = "database.db"
def migrate_to_many(db_path):
con = sqlite3.connect(db_path)
cur = con.cursor()
cur.execute("PRAGMA foreign_keys=OFF;")
try:
con.execute("BEGIN;")
cur.execute("ALTER TABLE youtube_info RENAME TO youtube_info_old;")
cur.execute("""
CREATE TABLE youtube_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
twitch_video_id VARCHAR NOT NULL,
yt_id VARCHAR,
yt_date VARCHAR,
yt_short_id VARCHAR,
yt_short_date VARCHAR,
yt_chat_SS_id VARCHAR,
yt_chat_SS_date VARCHAR,
yt_chat_over_id VARCHAR,
yt_chat_over_date VARCHAR,
FOREIGN KEY(twitch_video_id) REFERENCES twitch_videos(id) ON DELETE CASCADE
);
""")
cur.execute("CREATE INDEX ix_youtube_info_twitch_video_id ON youtube_info (twitch_video_id);")
cur.execute("""
INSERT INTO youtube_info (twitch_video_id, yt_id, yt_date, yt_short_id, yt_short_date, yt_chat_SS_id, yt_chat_SS_date, yt_chat_over_id, yt_chat_over_date)
SELECT id, yt_id, yt_date, yt_short_id, yt_short_date, yt_chat_SS_id, yt_chat_SS_date, yt_chat_over_id, yt_chat_over_date
FROM youtube_info_old;
""")
cur.execute("DROP TABLE youtube_info_old;")
con.commit()
print("Done - now 1-to-Many")
except Exception as e:
con.rollback()
raise e
finally:
cur.execute("PRAGMA foreign_keys=ON;")
con.close()
migrate_to_many(DB_PATH)
-57
View File
@@ -1,57 +0,0 @@
#!/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.Repository()
remove_vods()
if __name__ == "__main__":
main()
-35
View File
@@ -1,35 +0,0 @@
#!/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.Repository()
redownload_clips()
-56
View File
@@ -1,56 +0,0 @@
import os
import sys
# Finds the absolute path to 'project_directory' and adds it to Python's search path
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
sys.path.append(PROJECT_ROOT)
# Your original import will now work
from package.config import DB as DB
def remove_files():
datas = DB.get_downloaded()
for data in datas:
if data.clip_is is False:
#continue
video_path = f"download/videos/{data.id}/{data.id}.mp4"
else:
video_path = f"download/clips/{data.id}/{data.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():
remove_files()
print("done")
#redownload()
if __name__ == "__main__":
main()
-225
View File
@@ -1,225 +0,0 @@
"""
YouTube Description Updater
Requires: pip install google-api-python-client google-auth-oauthlib google-auth
1. Enable YouTube Data API v3 in Google Cloud Console
2. Create OAuth 2.0 credentials (Desktop App)
3. Download as client_secret.json
"""
import os
import sys
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
# Finds the absolute path to 'project_directory' and adds it to Python's search path
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
sys.path.append(PROJECT_ROOT)
from package.config import DB as DB
from package.youtube.config import CREDENTIALS as CREDENTIALS
from package.database import YoutubeInfo as YoutubeInfo
"""
SCOPES = ["https://www.googleapis.com/auth/youtube.force-ssl"]
TOKEN_FILE = "token.json"
CLIENT_SECRET_FILE = "client_secret.json"
def get_authenticated_service():
""Authenticates and returns a YouTube service object.""
creds = None
if os.path.exists(TOKEN_FILE):
creds = Credentials.from_authorized_user_file(TOKEN_FILE, SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
if not os.path.exists(CLIENT_SECRET_FILE):
raise FileNotFoundError(
f"{CLIENT_SECRET_FILE} not found. Download it from Google Cloud Console."
)
flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRET_FILE, SCOPES)
creds = flow.run_local_server(port=0)
with open(TOKEN_FILE, "w") as token:
token.write(creds.to_json())
return build("youtube", "v3", credentials=creds)
"""
def get_video_details(youtube, video_id: str):
"""Finds the video and returns its current snippet."""
try:
response = youtube.videos().list(
part="snippet,status",
id=video_id
).execute()
if not response["items"]:
print(f"❌ Video not found: {video_id}")
return None
return response["items"][0]
except HttpError as e:
print(f"❌ API Error while fetching video: {e}")
return None
def update_video_description(video_id: str, new_description: str, append: bool = False):
"""
Takes a YouTube video ID, finds the video, and changes its description.
Args:
video_id (str): The YouTube video ID (e.g., 'dQw4w9WgXcQ')
new_description (str): The new description to set
append (bool): If True, appends new_description to existing description instead of replacing
Returns:
dict | None: Updated video snippet or None on failure
"""
# Load credentials
if not CREDENTIALS:
print("❌ Error: YouTube API credentials not configured.")
return None
# Create YouTube API client
youtube = build('youtube', 'v3', credentials=CREDENTIALS)
video = get_video_details(youtube, video_id)
if not video:
return None
snippet = video["snippet"]
current_title = snippet["title"]
current_category = snippet.get("categoryId", "22")
print(f"Found video: '{current_title}' [{video_id}]")
print(f"Current description length: {len(snippet.get('description', ''))}")
current_description = snippet.get("description", "") or ""
# Decide final description
if append:
if new_description.strip().lower() in current_description.lower():
print("ℹ️ Description already contains that string, skipping")
return snippet
final_description = (snippet.get("description", "") + "\n\n" + new_description).strip()
else:
final_description = new_description
# YouTube requires you to send title + categoryId + description when updating snippet
body = {
"id": video_id,
"snippet": {
"title": current_title,
"description": final_description,
"categoryId": current_category,
# Keep existing tags, etc if you want
"tags": snippet.get("tags", []),
"defaultLanguage": snippet.get("defaultLanguage"),
}
}
try:
update_response = youtube.videos().update(
part="snippet",
body=body
).execute()
print(f"✅ Description updated for {video_id}")
print(f"New length: {len(final_description)}")
return update_response["snippet"]
except HttpError as e:
print(f"❌ Failed to update video {video_id}: {e}")
if "quotaExceeded" in str(e):
print("You hit the YouTube API quota.")
return None
def append_tag_if_not_exists(video_id: str, new_tag: str):
"""
Appends a new tag to a YouTube video if it doesn't already exist.
Case-insensitive check.
"""
if not new_tag or not new_tag.strip():
print("❌ Tag is empty")
return None
new_tag_clean = new_tag.strip()
# Load credentials
if not CREDENTIALS:
print("❌ Error: YouTube API credentials not configured.")
return None
# Create YouTube API client
youtube = build('youtube', 'v3', credentials=CREDENTIALS)
video = get_video_details(youtube, video_id)
if not video:
return None
snippet = video["snippet"]
existing_tags = snippet.get("tags", []) or []
existing_lower = {t.lower().strip() for t in existing_tags}
if new_tag_clean.lower() in existing_lower:
print(f"ℹ️ Tag '{new_tag_clean}' already exists on {video_id}, skipping")
return existing_tags
updated_tags = existing_tags + [new_tag_clean]
total_chars = sum(len(t) for t in updated_tags)
if total_chars > 450:
print(f"⚠️ Tags approaching 500 char limit ({total_chars})")
if len(updated_tags) > 15:
print(f"⚠️ {len(updated_tags)} tags - YouTube ignores after ~15-20")
print(f"Current tags: {existing_tags}")
print(f"Adding: '{new_tag_clean}'")
body = {
"id": video_id,
"snippet": {
"title": snippet["title"],
"description": snippet.get("description", ""),
"categoryId": snippet.get("categoryId", "22"),
"tags": updated_tags,
}
}
try:
resp = youtube.videos().update(part="snippet", body=body).execute()
print(f"✅ Tag '{new_tag_clean}' added to {video_id}")
return resp["snippet"].get("tags", [])
except HttpError as e:
print(f"❌ Failed to add tag: {e}")
return None
def main():
videos = DB.get_youtube_info_all()
for video in videos:
if video.yt_id:
update_video_description(video.yt_id, f"({video.id})", True)
append_tag_if_not_exists(video.yt_id, video.id)
if video.yt_short_id:
update_video_description(video.yt_short_id, f"({video.id})", True)
append_tag_if_not_exists(video.yt_short_id, video.id)
# --- Example Usage ---
if __name__ == "__main__":
main()
# Example 1: Replace description
# update_video_description("YOUR_VIDEO_ID_HERE", "This is my new description!\nGame: Valorant")
# Example 2: Append to description
# update_video_description("YOUR_VIDEO_ID_HERE", "\n\nCheck out https://twitch.tv/teampgp", append=True)
# For integration with your existing code:
#vid = input("Enter YouTube Video ID: ").strip()
#if vid:
# new_desc = input("Enter new description: ").strip()
# update_video_description(vid, new_desc)
+17
View File
@@ -0,0 +1,17 @@
#!/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}
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import fcntl
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
import package.process_uploads as process_uploads
def lock_file():
# Open a lock file
lock_file_path = "/tmp/python_script.lock"
fp = open(lock_file_path, 'w')
try:
# Try to grab an exclusive lock without blocking (LOCK_NB)
fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
print("Another instance is already running. Exiting.")
sys.exit(1)
# Your script logic goes here
print("Running exclusively on Linux/macOS...")
if __name__ == "__main__":
lock_file()
upload = True
if upload is False:
# Get Twitch Video Info
asyncio.run(video_info())
# Download Twitch Videos
download_videos()
download_thumbnails()
#Build
build_videos()
else:
process_uploads()
print("====================================================")
print(f"✅ Done.")
print("====================================================")
-48
View File
@@ -1,48 +0,0 @@
# 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 .video_editing.build_shorts import fit_to_9_16_letterbox
from .build_videos import build_videos
from .config import CHANNEL_NAME, TZ, DB
from .hash_tags import get_top_nouns
from .process_uploads import process_uploads
from .transcribe_video import transcribe_to_srt
from .transcribe_client import transcribe_to_srt_remote
from .utils import get_chapters, get_video_id_from_chat, write_csv, run_command
# 4. Expose Subpackages
from . import twitch
from . import youtube
from . import database
# (Optional) Defines what is exposed during 'from package import *'
__all__ = [
"fit_to_9_16_letterbox"
"build_videos",
"CHANNEL_NAME",
"TZ",
"DB",
"get_top_nouns",
"process_uploads",
"transcribe_to_srt",
"transcribe_to_srt_remote",
"write_csv",
"run_command",
"get_chapters",
"get_video_id_from_chat"
"twitch",
"youtube"
]
-103
View File
@@ -1,103 +0,0 @@
#!/usr/bin/env python3
from datetime import datetime
import package.video_editing.build_shorts as yt_shorts
import package.transcribe_video as transcribe_video
import package.transcribe_client as transcribe_client
import package.twitch.chat_vod as chat_vod
import package.video_editing.highlights as highlights
from package.config import DB as DB
from package.config import TZ as TZ
def build_chat_video():
chats = DB.get_unuploaded_chats()
for chat in chats:
print("====================================================")
print(f"🚀 Chat Video: {chat.title}")
print("====================================================")
chat_vod.combine_twitch_vod_and_chat(f"download/videos/{chat.id}/{chat.id}.mp4", "side-by-side")
print("====================================================")
print(f"✅ Processing: Chat Video Done.")
print("====================================================")
def build_transcribe():
downloads = DB.get_downloaded()
for download in downloads:
if download.clip_is:
target_dir = f"download/clips"
else:
target_dir = f"download/videos"
print("====================================================")
print(f"🚀 Transcribe: {download.title}")
print("====================================================")
#transcribe_video.transcribe_to_srt(f"{target_dir}/{unuploaded.id}/{unuploaded.id}.mp4", True)
transcribe_client.transcribe_to_srt_remote(f"{target_dir}/{download.id}/{download.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"
count = 1
for short in shorts:
target_dir = f"download/clips/{short.id}/{short.id}.mp4"
print("====================================================")
print(f"🚀 Processing: Clip to Youtube Short {short.title}")
print("====================================================")
print(f"{count}/{len(shorts)}")
created_at = datetime.strptime(short.created_at, "%Y-%m-%d %H:%M:%S%z")
created_at = created_at.astimezone(TZ)
#ToDo
yt_shorts.fit_to_9_16_letterbox(target_dir, top_txt, f"Clipped By: {short.creator_name},\n on {created_at.strftime("%Y-%m-%d %H:%M:%S %Z")}")
count += 1
print("====================================================")
print(f"✅ Processing: Clip to Youtube Short Done.")
print("====================================================")
def build_highlights():
lights = DB.get_downloaded()
top_txt = "TeamPGP Live on Twitch...\n Every Friday and Sunday @7:30 ET.\n twitch.tv/teampgp"
count = 1
for light in lights:
target_dir = f"download/clips/{light.id}/{light.id}.mp4"
print("====================================================")
print(f"🚀 Processing: Highlights {light.title}")
print("====================================================")
print(f"{count}/{len(lights)}")
created_at = datetime.strptime(light.created_at, "%Y-%m-%d %H:%M:%S%z")
created_at = created_at.astimezone(TZ)
#ToDo
highlights.highlights(target_dir, [], 5, 8)
#yt_shorts.fit_to_9_16_letterbox(target_dir, top_txt, f"Clipped By: {light.creator_name},\n on {created_at.strftime("%Y-%m-%d %H:%M:%S %Z")}")
count += 1
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()
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env python3
from zoneinfo import ZoneInfo
#import package.database as database
from package.database.database import Repository
CHANNEL_NAME = "teampgp"
TZ = ZoneInfo("America/Toronto")
DB = Repository()
-15
View File
@@ -1,15 +0,0 @@
# package/database/__init__.py
# Expose components from individual modules using relative imports
from .database import TwitchVideo, Data, YoutubeInfo, TiktokInfo, Highlight, Chapter, Repository
# Explicitly define what is exposed when importing the subpackage
__all__ = [
"TwitchVideo",
"Data",
"YoutubeInfo",
"TiktokInfo",
"Highlight",
"Chapter",
"Repository",
]
-319
View File
@@ -1,319 +0,0 @@
from sqlalchemy import create_engine, Column, String, Integer, Boolean, Float, ForeignKey, select
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from typing import Any
# 1. Define the base class for declarative models
Base = declarative_base()
# 2. Define the twitch_videos table
class TwitchVideo(Base):
__tablename__ = "twitch_videos"
id = Column(String, primary_key=True)
title = Column(String, nullable=False)
created_at = Column(String, nullable=False)
view_count = Column(Integer, nullable=False)
duration = Column(String, nullable=False)
url = Column(String, nullable=False)
thumbnail_url = Column(String, nullable=False)
game_id = Column(String, nullable=False)
game_name = Column(String, nullable=False)
stream_id = Column(String, nullable=False)
creator_name = Column(String, nullable=False)
clip_is = Column(Boolean, nullable=False, default=False)
downloaded = Column(Boolean, nullable=False, default=False)
uploaded_yt = Column(Boolean, nullable=False, default=False)
uploaded_yt_chats = Column(Boolean, nullable=False, default=False)
uploaded_yt_shorts = Column(Boolean, nullable=False, default=False)
uploaded_tiktok = Column(Boolean, nullable=False, default=False)
# This now actually points to twitch_videos.id
related_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=True)
# Relationship back to YoutubeInfo (uselist=True enforces a 1-to-many structure)
youtube_info = relationship("YoutubeInfo", back_populates="twitch_video", uselist=True)
tiktok_info = relationship("TiktokInfo", back_populates="twitch_video", uselist=True)
chapters = relationship("Chapter", back_populates="twitch_video", cascade="all, delete-orphan")
# THIS WAS MISSING - this is what Highlight is looking for
highlights = relationship("Highlight", back_populates="twitch_video", cascade="all, delete-orphan")
# 3. Define the data table
class Data(Base):
__tablename__ = "data"
# In SQLAlchemy, an Integer primary key automatically acts as AUTOINCREMENT in SQLite
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
date_shorts = Column(String, nullable=False)
date_videos = Column(String, nullable=False)
date_videos_chats = Column(String, nullable=False)
date_tiktok = Column(String, nullable=False)
class YoutubeInfo(Base):
__tablename__ = "youtube_info"
id = Column(Integer, primary_key=True, autoincrement=True)
twitch_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=False)
yt_id = Column(String, nullable=True)
yt_date = Column(String, nullable=True)
yt_short_id = Column(String, nullable=True)
yt_short_date = Column(String, nullable=True)
yt_chat_SS_id = Column(String, nullable=True)
yt_chat_SS_date = Column(String, nullable=True)
yt_chat_over_id = Column(String, nullable=True)
yt_chat_over_date = Column(String, nullable=True)
# Relationship linking back to the twitch video record
twitch_video = relationship("TwitchVideo", back_populates="youtube_info")
class TiktokInfo(Base):
__tablename__ = "tiktok_info"
id = Column(Integer, primary_key=True, autoincrement=True)
twitch_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=False)
tiktok_id = Column(String, nullable=True)
tiktok_date = Column(String, nullable=True)
# Relationship linking back to the twitch video record
twitch_video = relationship("TwitchVideo", back_populates="tiktok_info")
class Highlight(Base):
__tablename__ = "highlights"
id = Column(Integer, primary_key=True, autoincrement=True)
twitch_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=False)
start_milliseconds = Column(Float, nullable=False)
length_milliseconds = Column(Float, nullable=False)
start_timestamp = Column(String, nullable=False)
end_timestamp = Column(String, nullable=False)
description = Column(String, nullable=False)
score = Column(Float, nullable=False)
# Relationship linking back to the twitch video record
twitch_video = relationship("TwitchVideo", back_populates="highlights")
class Chapter(Base):
__tablename__ = "chapters"
id = Column(Integer, primary_key=True, autoincrement=True)
twitch_video_id = Column(String, ForeignKey("twitch_videos.id"), nullable=False)
start_milliseconds = Column(Integer, nullable=False)
length_milliseconds = Column(Integer, nullable=False)
description = Column(String, nullable=True)
type = Column(String, nullable=True)
game_display_name = Column(String, nullable=True)
game_id = Column(String, nullable=True)
# Relationship linking back to the twitch video record
twitch_video = relationship("TwitchVideo", back_populates="chapters")
class Repository:
def __init__(self, db_path: str = "database.db"):
# 4. Connect to the SQLite file and generate the tables
# This will create 'database.db' in the current working directory if it doesn't exist.
engine = create_engine(f"sqlite:///{db_path}", echo=True)
Base.metadata.create_all(engine)
# 5. Optional: Set up a session factory for interacting with the database
Session = sessionmaker(bind=engine)
self.session = Session()
def set_related_video(self, record_id: str, related_video_id: str):
record = self.session.get(TwitchVideo, record_id)
if record:
setattr(record, "related_video", related_video_id)
self.session.commit()
else:
print(f"❌ Record {record_id} not found.")
def __mark_as(self, record_id: str, field_name: str, value: bool = True):
record = self.session.get(TwitchVideo, record_id)
if record:
setattr(record, field_name, value)
self.session.commit()
else:
print(f"❌ Record {record_id} not found in __mark_as.")
def mark_as_uploaded_shorts(self, record_id: str):
"""Flags a specific row record to uploaded_yt_shorts (1)."""
self.__mark_as(record_id, "uploaded_yt_shorts")
def mark_as_uploaded_chats(self, record_id: str):
"""Flags a specific row record to uploaded_yt_chats (1)."""
self.__mark_as(record_id, "uploaded_yt_chats")
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")
def mark_as_downloaded(self, record_id: str):
"""Flags a specific row record to downloaded (1)."""
self.__mark_as(record_id, "downloaded")
def mark_as_uploaded_tiktok(self, record_id: str):
"""Flags a specific row record to uploaded_tiktok (1)."""
self.__mark_as(record_id, "uploaded_tiktok")
def unmark_as_download(self, record_id: str):
"""Flags a specific row record to downloaded (0)."""
self.__mark_as(record_id, "downloaded", False)
def update_datetime(self, record_id: str, created_at: str):
record = self.session.get(TwitchVideo, record_id)
if record:
record.created_at = created_at
self.session.commit()
else:
print(f"❌ Record {record_id} not found in __mark_as.")
def __get_unuploaded(self, field_name: str, also_clause: Any = None) -> list[TwitchVideo]:
"""Retrieve all rows that were downloaded but not uploaded using SQLAlchemy."""
# 1. Validation: Verify the string corresponds to an actual column
if not hasattr(TwitchVideo, field_name):
raise ValueError(f"🚨 Invalid column name: '{field_name}' does not exist on TwitchVideo.")
# Get the actual SQLAlchemy column object dynamically
status_column = getattr(TwitchVideo, field_name)
# 2. Build the query programmatically
stmt = select(TwitchVideo).where(
TwitchVideo.downloaded == True,
status_column == False
)
# 3. Safely append additional filters if provided
if also_clause is not None:
stmt = stmt.where(also_clause)
# 4. Execute and return results
return self.session.scalars(stmt).all()
def get_unuploaded_shorts(self) -> list[TwitchVideo]:
"""Retrieve all rows that were download but not uploaded_yt_shorts"""
return self.__get_unuploaded("uploaded_yt_shorts", (TwitchVideo.clip_is == True))
def get_unuploaded_chats(self) -> list[TwitchVideo]:
"""Retrieve all rows that were download but not uploaded_yt_chats"""
return self.__get_unuploaded("uploaded_yt_chats", (TwitchVideo.clip_is == False))
def get_unuploaded(self) -> list[TwitchVideo]:
"""Retrieve all rows that were download but not uploaded_yt"""
return self.__get_unuploaded("uploaded_yt")
def get_undownloaded(self) -> list[TwitchVideo]:
stmt = select(TwitchVideo).where(TwitchVideo.downloaded == False)
return self.session.scalars(stmt).all()
def get_unuploaded_tiktok(self) -> list[TwitchVideo]:
"""Retrieve all rows that were download but not uploaded_tiktok"""
return self.__get_unuploaded("uploaded_tiktok", (TwitchVideo.clip_is == True))
def get_downloaded(self) -> list[TwitchVideo]:
stmt = select(TwitchVideo).where(TwitchVideo.downloaded == True)
return self.session.scalars(stmt).all()
def get_clips(self) -> list[TwitchVideo]:
"""Retrieves all rows that are clip_is"""
stmt = select(TwitchVideo).where(TwitchVideo.clip_is == True)
return self.session.scalars(stmt).all()
def get_vods(self) -> list[TwitchVideo]:
stmt = select(TwitchVideo).where(TwitchVideo.clip_is == False)
return self.session.scalars(stmt).all()
def __update_date(self, field_name: str, date: str):
record_id = 1
record = self.session.get(Data, record_id)
if record:
setattr(record, field_name, date)
self.session.commit()
else:
print(f"❌ Record {record_id} not found in __update_date.")
def update_date_shorts(self, date: str):
self.__update_date("date_shorts", date)
def update_date_chats(self, date: str):
self.__update_date("date_videos_chat", date)
def get_data(self) -> Data:
stmt = select(Data)
return self.session.scalars(stmt).first()
def get_youtube_info_for_video(self, video_id: str) -> YoutubeInfo | None:
"""Retrieve all YouTubeInfo records associated with a specific TwitchVideo ID."""
stmt = select(YoutubeInfo).where(YoutubeInfo.twitch_video_id == video_id)
return self.session.scalars(stmt).first()
def get_youtube_info_all(self) -> list[YoutubeInfo]:
return self.session.scalars(select(YoutubeInfo)).all()
def insert(self, data: TwitchVideo | Data | YoutubeInfo | Chapter | list[YoutubeInfo] | list[Chapter] | TiktokInfo | Highlight):
"""
Handles insertion/updating for any of the defined models,
using session merge/add for SQLAlchemy 2.0 best practices.
Accepts single objects or lists of objects for collection handling.
"""
try:
if isinstance(data, TwitchVideo):
# 1. Check if the record exists
stmt = select(TwitchVideo).where(TwitchVideo.id == data.id)
existing_record = self.session.scalar(stmt)
if existing_record:
# 2. Update relevant fields on the managed instance
if existing_record.view_count != data.view_count or existing_record.duration != data.duration:
existing_record.view_count = data.view_count
existing_record.duration = data.duration
print(f"🔄 TwitchVideo record {data.id} updated successfully!")
# Note: Add more field updates here as needed
else:
# 3. Add new instance
self.session.add(data)
print(f"➕ New TwitchVideo record with ID {data.id} inserted successfully!")
elif isinstance(data, Data):
self.session.merge(data)
print(f"➕ Data record merged/inserted successfully!")
elif isinstance(data, YoutubeInfo):
# Handle single YoutubeInfo object
self.session.merge(data)
print(f"➕ YoutubeInfo record merged/inserted successfully!")
elif isinstance(data, Chapter):
# Handle single Chapter object
self.session.merge(data)
print(f"➕ Chapter record merged/inserted successfully!")
elif isinstance(data, list):
# Handle lists of records (YoutubeInfo or Chapter)
record_type = type(data[0])
if record_type in (YoutubeInfo, Chapter):
for item in data:
self.session.merge(item)
print(f"➕ Successfully processed {len(data)} records of type {record_type.__name__}.")
else:
raise TypeError(f"Unsupported list content type: {record_type.__name__}")
elif isinstance(data, TiktokInfo):
# Handle single TiktokInfo object
self.session.merge(data)
print(f"➕ TiktokInfo record merged/inserted successfully!")
elif isinstance(data, Highlight):
# Handle single Highlight object
self.session.merge(data)
print(f"➕ Highlight record merged/inserted successfully!")
else:
raise TypeError(f"Unsupported data type for insertion: {type(data).__name__}")
# 4. Commit the transaction
self.session.flush() # Flush first to ensure IDs are populated
except Exception as e:
self.session.rollback()
print(f"❌ A critical error occurred during record insertion/update: {e}")
raise
self.session.commit()
-104
View File
@@ -1,104 +0,0 @@
#!/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)
-337
View File
@@ -1,337 +0,0 @@
#!/usr/bin/env python3
import os
from pathlib import Path
from datetime import datetime, timedelta
import package.utils as utils
from package.config import TZ as TZ
from package.config import DB as DB
from package.hash_tags import get_top_nouns as get_top_nouns
from package.youtube.uploader import upload_video as upload_video
from package.youtube.uploader import CategoryId as CategoryId
from package.tiktok.uploader import upload_to_tiktok as upload_to_tiktok
from package.database import TwitchVideo, YoutubeInfo, TiktokInfo, Data, TiktokInfo
def process_videos(video_type: bool = True, limit: int = 22):
count = 0 # count how many videos we have upload and stop at var: limit
videos = DB.get_unuploaded_chats() if video_type else DB.get_unuploaded_shorts()
file_type = "download/video" if video_type else "download/clips"
meta_data: Data = DB.get_data()
#current_time = datetime.now(TZ)
#current_time = current_time.replace(hour=0, minute=0, second=0, microsecond=0)
#meta_data.date_videos = current_time
#meta_data.date_shorts = current_time
#meta_data.date_tiktok = current_time
#meta_data.date_videos_chats = current_time
#DB.insert(meta_data) # ensure we have a record in the database
current_time = datetime.now(TZ)
next_time = meta_data.date_videos if video_type else meta_data.date_shorts
release_time = datetime.strptime(next_time, "%Y-%m-%d %H:%M:%S%z")
if current_time > release_time:
release_time = release_time.replace(hour=12, minute=0, second=0, microsecond=0)
# if noon today is already past, push to tomorrow
if release_time <= current_time:
release_time += timedelta(days=1)
for video in videos:
#We reached limit stop uploading videos
if count >= limit:
break
base_path = Path(f"{file_type}/{video.id}/{video.id}")
original_path = base_path.with_suffix(".mp4")
if not original_path.exists():
print(f"❌ Error: no video at {original_path}")
continue
# Build candidate edited files
if video_type:
primary_edited = base_path.parent / f"{base_path.name}_SS_with_chat.mp4"
fallback_edited = base_path.parent / f"{base_path.name}_over_with_chat.mp4"
else:
primary_edited = base_path.parent / f"{base_path.name}_gaussian_9_16.mp4"
fallback_edited = base_path.parent / f"{base_path.name}_black_9_16.mp4"
file_path = primary_edited if primary_edited.exists() else fallback_edited
if not file_path.exists():
continue
file_csv = base_path.parent / f"{base_path.name}_{meta_data.name}.csv"
srt_path = base_path.with_suffix(".srt")
created_at = datetime.strptime(video.created_at, "%Y-%m-%d %H:%M:%S%z").astimezone(TZ)
category_id = CategoryId.GAMING
if video_type:
origin_clipped = "Originally Streamed on"
else:
origin_clipped = f"Clipped By: {video.creator_name}, on"
privatcyStatus = 'private'
tags = ['gaming', 'TeamPGP', f'{video.game_name}', f'{video.creator_name}', 'Level1Techs', 'twitch']
if not video_type:
tags.extend(['shorts', 'twitch_clips', 'clips'])
tags.extend([video.id])
if srt_path.exists():
try:
tags.extend(get_top_nouns(str(srt_path), (15 - len(tags))))
except Exception as e:
print(f"⚠️ Could not get nouns from {srt_path}: {e}")
hashs = " ".join([f"#{t}" for t in tags])
description = f"Game: {video.game_name}, {origin_clipped} {created_at.strftime("%Y-%m-%d %H:%M:%S %Z (%:z)")}, {hashs} Every Friday and Sunday @7:30 ET https://twitch.tv/teampgp"
youtube_info = DB.get_youtube_info_for_video(video.id) or YoutubeInfo(twitch_video_id=video.id)
if youtube_info.yt_id:
main_video_id = youtube_info.yt_id
else:
main_video_id = upload_video(str(original_path), video.title, category_id, f"{description} \n\n({video.id})", 'unlisted', tags)
count += 1
if not main_video_id:
print(f"❌ Error: main video upload failed for {original_path}")
continue
youtube_info.yt_id = main_video_id
youtube_info.yt_date = datetime.now(TZ)
# Upload secondary
secondary_desc = f"{description} Original Full size @ https://youtu.be/{main_video_id} \n\n({video.id})"
secondary_video_id = upload_video(str(file_path), video.title, category_id, secondary_desc, 'private', tags, release_time)
if not secondary_video_id:
print(f"❌ Error: secondary upload failed for {file_path}")
DB.insert(youtube_info) # save main id at least
continue
count += 1
# Success - write CSV and DB
data_csv = [main_video_id, secondary_video_id, release_time.isoformat()]
utils.write_csv([data_csv], str(file_csv))
if video_type:
DB.mark_as_uploaded_chats(video.id)
youtube_info.yt_chat_SS_id = secondary_video_id
youtube_info.yt_chat_SS_date = datetime.now(TZ)
else:
DB.mark_as_uploaded_shorts(video.id)
youtube_info.yt_short_id = secondary_video_id
youtube_info.yt_short_date = datetime.now(TZ)
DB.insert(youtube_info)
release_time += timedelta(days=0.5)
# Update global release pointer once at end
if video_type:
DB.update_date_chats(release_time)
else:
DB.update_date_shorts(release_time)
def process_tiktoks():
tiktoks = DB.get_unuploaded_tiktok()
data = DB.get_data()
meta_data: Data = data[0]
release_time = meta_data.date_tiktok
current_time = datetime.now(TZ)
release_time = datetime.strptime(release_time, "%Y-%m-%d %H:%M:%S%z")
count = 0
if current_time > release_time:
release_time = current_time
release_time = release_time.replace(hour=12, minute=0, second=0, microsecond=0)
for tiktok in tiktoks:
if count >= 10:
break
created_at = datetime.strptime(tiktok.created_at, "%Y-%m-%d %H:%M:%S%z").astimezone(TZ)
tags = ["Twitch", "TeamPGP", f"{tiktok.game_name}", f"{tiktok.creator_name}", "Level1Techs", "clips"]
origin_clipped = f"Clipped By: {tiktok.creator_name}, on"
description = f"Game: {tiktok.game_name}, {origin_clipped} {created_at.strftime("%Y-%m-%d %H:%M:%S %Z (%:z)")}, Every Friday and Sunday @7:30 ET https://twitch.tv/teampgp"
original_path = f"download/clips/{tiktok.id}/{tiktok.id}.mp4"
file_path = f"download/clips/{tiktok.id}/{tiktok.id}_gaussian_9_16.mp4"
file_csv = f"download/clips/{tiktok.id}/{tiktok.id}_{meta_data.name}.csv"
srt_path = f"download/clips/{tiktok.id}/{tiktok.id}.srt"
try:
tags.extend(get_top_nouns(str(srt_path), (15 - len(tags))))
except Exception as e:
print(f"⚠️ Could not get nouns from {srt_path}: {e}")
if not os.path.exists(file_path):
file_path = f"download/clips/{tiktok.id}/{tiktok.id}_black_9_16.mp4"
if not os.path.exists(file_path):
continue
if tiktok_video_id := upload_to_tiktok(file_path, description, tags, "teampgpunofficialarchive"):
tiktok_info = TiktokInfo(twitch_video_id=tiktok.id, tiktok_id=tiktok_video_id, tiktok_date=datetime.now(TZ))
DB.insert(tiktok_info)
count += 1
def process_shorts():
process_videos(False)
return
shorts = DB.get_unuploaded_shorts()
data = DB.get_data()
meta_data: Data = data[0]
release_time = meta_data.date_shorts
current_time = datetime.now(TZ)
release_time = datetime.strptime(release_time, "%Y-%m-%d %H:%M:%S%z")
if current_time > release_time:
release_time = current_time
release_time = release_time.replace(hour=12, minute=0, second=0, microsecond=0)
for short in shorts:
original_path = f"download/clips/{short.id}/{short.id}.mp4"
file_path = f"download/clips/{short.id}/{short.id}_gaussian_9_16.mp4"
file_csv = f"download/clips/{short.id}/{short.id}_{meta_data.name}.csv"
if not os.path.exists(file_path):
file_path = f"download/clips/{short.id}/{short.id}_black_9_16.mp4"
if not os.path.exists(file_path):
continue
#short.title = short.title
created_at = datetime.strptime(short.created_at, "%Y-%m-%d %H:%M:%S%z")
created_at = created_at.astimezone(TZ)
categoryId = CategoryId.GAMING
description = f"Game: {short.game_name}, Clipped By: {short.creator_name}, on {created_at.strftime("%Y-%m-%d %H:%M:%S %Z (%:z)")}, #Shorts #Clips #Twitch Every Friday and Sunday @7:30 ET https://twitch.tv/teampgp"
privatcyStatus = 'private'
tags = ['shorts', 'gaming', 'TeamPGP', f'{short.game_name}', f'{short.creator_name}', 'twitch_clips', 'clips', 'Level1Techs', 'twitch']
tags.extend(get_top_nouns(f"download/clips/{short.id}/{short.id}.srt"))
youtube_info = DB.get_youtube_info(short.id)
if not youtube_info:
youtube_info = YoutubeInfo()
if os.path.exists(original_path):
yt_video_id = None
if (youtube_info.id == short.id and youtube_info.yt_id is not None) or (yt_video_id := upload_video(original_path, short.title, categoryId, description, 'unlisted', tags)):
if youtube_info.id == short.id:
yt_video_id = youtube_info.yt_short_id
data_csv = [yt_video_id, current_time]
release_time += timedelta(days=1)
# Assign / Update values on the object
youtube_info.id = short.id
youtube_info.yt_id = yt_video_id
youtube_info.yt_date = datetime.now(TZ)
if yt_video_id := upload_video(file_path, short.title, categoryId, f"{description} Original Full size @ https://youtu.be/{yt_video_id}", privatcyStatus, tags, release_time):
data_csv.extend([yt_video_id, release_time])
DB.mark_as_uploaded_yt(short.id)
DB.mark_as_uploaded_shorts(short.id)
utils.write_csv([data_csv], file_csv)
youtube_info.yt_short_id = yt_video_id
youtube_info.yt_short_date = datetime.now(TZ)
else:
print(f"Why is this short allreay update")
DB.insert(youtube_info)
else:
break
DB.update_date_shorts(release_time)
def process_chats():
process_videos(True)
return
chats = DB.get_unuploaded_chats()
data = DB.get_data()
meta_data: Data = data[0]
release_time = meta_data.date_shorts
current_time = datetime.now(TZ)
release_time = datetime.strptime(release_time, "%Y-%m-%d %H:%M:%S%z")
if current_time > release_time:
release_time = current_time
release_time = release_time.replace(hour=12, minute=0, second=0, microsecond=0)
for chat in chats:
original_path = f"download/videos/{chat.id}/{chat.id}.mp4"
file_path = f"download/videos/{chat.id}/{chat.id}_SS_with_chat.mp4"
file_csv = f"download/videos/{chat.id}/{chat.id}_{meta_data.name}.csv"
if not os.path.exists(file_path):
file_path = f"download/videos/{chat.id}/{chat.id}_over_with_chat.mp4"
if not os.path.exists(file_path):
continue
#chat.title = chat.title
created_at = datetime.strptime(chat.created_at, "%Y-%m-%d %H:%M:%S%z")
created_at = created_at.astimezone(TZ)
categoryId = CategoryId.GAMING
description = f"Game: {chat.game_name}, Originally Streamed on {created_at.strftime("%Y-%m-%d %H:%M:%S %Z (%:z)")}, #Twitch Every Friday and Sunday @7:30 ET https://twitch.tv/teampgp"
privatcyStatus = 'private'
tags = ['gaming', 'TeamPGP', f'{chat.game_name}', f'{chat.creator_name}', 'Level1Techs', 'twitch']
tags.extend(get_top_nouns(f"download/video/{chat.id}/{chat.id}.srt"))
youtube_info = DB.get_youtube_info(chat.id)
if not youtube_info:
youtube_info = YoutubeInfo()
if os.path.exists(original_path):
yt_video_id = None
if (youtube_info.id == chat.id and youtube_info.yt_id is not None) or (yt_video_id := upload_video(original_path, chat.title, categoryId, description, 'unlisted', tags)):
if youtube_info.id == chat.id:
yt_video_id = youtube_info.yt_short_id
data_csv = [yt_video_id, current_time]
release_time += timedelta(days=1)
# Assign / Update values on the object
youtube_info.id = chat.id
youtube_info.yt_id = yt_video_id
youtube_info.yt_date = datetime.now(TZ)
if yt_video_id := upload_video(file_path, chat.title, categoryId, f"{description} Original no chat @ https://youtu.be/{yt_video_id}", privatcyStatus, tags, release_time):
data_csv.extend([yt_video_id, release_time])
DB.mark_as_uploaded_yt(chat.id)
DB.mark_as_uploaded_shorts(chat.id)
utils.write_csv([data_csv], file_csv)
youtube_info.yt_short_id = yt_video_id
youtube_info.yt_short_date = datetime.now(TZ)
else:
print(f"Why is this short allreay update")
DB.insert(youtube_info)
else:
break
DB.update_date_shorts(release_time)
def process_uploads():
#process_tiktoks()
process_shorts()
#process_chats()
if __name__ == "__main__":
pass
-9
View File
@@ -1,9 +0,0 @@
# package/tiktok/__init__.py
# Expose components from individual modules using relative imports
from .uploader import upload_to_tiktok
# Explicitly define what is exposed when importing the subpackage
__all__ = [
"upload_to_tiktok"
]
-74
View File
@@ -1,74 +0,0 @@
import json
import requests
from pathlib import Path
SECRETS_FILE = Path(__file__).parent.parent / "tiktok_secrets.json"
# if your file is at /home/richard/python_scripts/tiktok_secrets.json
# use: Path("/home/richard/python_scripts/tiktok_secrets.json")
def load_secrets():
with open(SECRETS_FILE) as f:
secrets = json.load(f)
return secrets["client_key"], secrets["client_secret"], secrets["access_token"], secrets.get("refresh_token", "")
API_BASE = "https://open.tiktokapis.com/v2"
def upload_to_tiktok_api(video_path: str, title: str, privacy="PUBLIC_TO_EVERYONE"):
client_key, client_secret, access_token, refresh_token = load_secrets()
video_path = Path(video_path)
file_size = video_path.stat().st_size
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json; charset=UTF-8"
}
# 1. INIT
init_payload = {
"source_info": {
"source": "FILE_UPLOAD",
"video_size": file_size,
"chunk_size": file_size,
"total_chunk_count": 1
}
}
r = requests.post(f"{API_BASE}/post/publish/video/init/", headers=headers, json=init_payload)
r.raise_for_status()
data = r.json()
if data.get("error", {}).get("code") != "ok":
raise Exception(f"Init failed: {data}")
publish_id = data["data"]["publish_id"]
upload_url = data["data"]["upload_url"]
# 2. UPLOAD
with open(video_path, "rb") as f:
put_headers = {
"Content-Range": f"bytes 0-{file_size-1}/{file_size}",
"Content-Type": "video/mp4"
}
requests.put(upload_url, headers=put_headers, data=f).raise_for_status()
# 3. PUBLISH
publish_payload = {
"publish_id": publish_id,
"post_info": {
"title": title[:2200],
"privacy_level": privacy,
"disable_duet": False,
"disable_comment": False,
"disable_stitch": False,
"video_cover_timestamp_ms": 1000
}
}
pr = requests.post(f"{API_BASE}/post/publish/", headers=headers, json=publish_payload)
pr.raise_for_status()
return pr.json()
if __name__ == "__main__":
result = upload_to_tiktok_api(
"/home/richard/python_scripts/download/clips/ProductiveConfidentWombatThunBeast/ProductiveConfidentWombatThunBeast_gaussian_9_16.mp4",
"Testing upload #Twitch #TeamPGP #Stellaris"
)
print(result)
-173
View File
@@ -1,173 +0,0 @@
#!/usr/bin/env python3
#!/usr/bin/env python3
import threading
# --- HOTFIX for Python 3.13.5 _DeleteDummyThreadOnDel spam ---
# The CPython fix in 3.13.4 didn't fully kill it for debugpy/asyncio cases
if hasattr(threading, '_DeleteDummyThreadOnDel'):
threading._DeleteDummyThreadOnDel.__del__ = lambda self: None
import os
import shutil
# ... rest of your imports
import os
import shutil
import subprocess
import json
from pathlib import Path
import wave
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
CHUNK_DURATION_SEC = 600 # target chunk size
OVERLAP_SEC = 8 # overlap to prevent cut words
VAD_SEARCH_WINDOW = 15 # look for silence within 15s of target
WORKER_URL = "http://192.168.5.55:8000/transcribe"
MAX_WORKERS = 4 # perfect for 4070 Ti Super 16GB
def get_duration(video_path: str) -> float:
cmd = ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", video_path]
result = subprocess.run(cmd, capture_output=True, text=True)
return float(json.loads(result.stdout)["format"]["duration"])
def find_silence_near(video_path: str, target_time: float) -> float:
"""Find nearest silence within VAD_SEARCH_WINDOW of target_time"""
start_search = max(0, target_time - VAD_SEARCH_WINDOW/2)
cmd = [
"ffmpeg", "-ss", str(start_search), "-t", str(VAD_SEARCH_WINDOW),
"-i", video_path, "-af", "silencedetect=noise=-30dB:d=0.5",
"-f", "null", "-"
]
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
# Parse last silence end before target
best = target_time
for line in result.stderr.splitlines():
if "silence_end:" in line:
try:
t = float(line.split("silence_end:")[1].split("|")[0].strip())
abs_t = start_search + t
if abs(abs_t - target_time) < abs(best - target_time):
best = abs_t
except: pass
return best
def extract_audio_and_chunk_smart(video_path: str, output_dir: Path):
print(f"🚀 Smart chunking: target {CHUNK_DURATION_SEC}s + {OVERLAP_SEC}s overlap")
output_dir.mkdir(parents=True, exist_ok=True)
total_dur = get_duration(video_path)
print(f"📏 Total duration: {total_dur/60:.1f} min")
chunks = []
start = 0.0
idx = 0
while start < total_dur:
# Find smart cut point
target_end = start + CHUNK_DURATION_SEC
if target_end >= total_dur:
end = total_dur
else:
end = find_silence_near(video_path, target_end)
# clamp
end = max(start + CHUNK_DURATION_SEC * 0.7, min(end, start + CHUNK_DURATION_SEC * 1.3))
chunk_path = output_dir / f"chunk_{idx:03d}.wav"
# Extract with overlap for next chunk
extract_end = end + (OVERLAP_SEC if end < total_dur else 0)
cmd = [
"ffmpeg", "-y", "-ss", str(start), "-to", str(extract_end),
"-i", video_path, "-vn", "-ac", "1", "-ar", "16000", "-acodec", "pcm_s16le",
str(chunk_path)
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
chunks.append((chunk_path, start, end)) # path, true_start, true_end (without overlap)
print(f" Chunk {idx}: {start:.1f}s -> {end:.1f}s (file to {extract_end:.1f}s)")
if end >= total_dur:
break
start = end - OVERLAP_SEC/2 # start next chunk with half overlap
idx += 1
return chunks
def format_srt_time(seconds: float):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def send_chunk_for_transcription(args):
chunk_path, true_start, true_end, chunk_idx = args
try:
with open(chunk_path, 'rb') as f:
files = {'file': (chunk_path.name, f, 'audio/wav')}
resp = requests.post(WORKER_URL, files=files, timeout=1200)
resp.raise_for_status()
data = resp.json()
# Filter to only keep segments within true window (dedupe overlap)
filtered = []
for seg in data['segments']:
abs_start = true_start + seg['start']
# keep if it starts before the true_end (allow small spill)
if abs_start < true_end + OVERLAP_SEC/2:
# if it's in overlap zone of NEXT chunk, only keep first half
if abs_start < true_end - OVERLAP_SEC/2 or chunk_idx == 0:
filtered.append(seg)
elif seg['start'] < (true_end - true_start) - OVERLAP_SEC/2 + 1.0:
filtered.append(seg)
return chunk_idx, true_start, filtered, None
except Exception as e:
return chunk_idx, true_start, None, str(e)
def transcribe_to_srt_remote(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() and not force:
print(f"❌ SRT already exists: {srt_path}")
return
if srt_path.exists():
srt_path.unlink()
audio_chunks = extract_audio_and_chunk_smart(str(video_path_obj), temp_dir)
jobs = [(path, s, e, idx) for idx, (path, s, e) in enumerate(audio_chunks)]
results = []
executor = ThreadPoolExecutor(max_workers=MAX_WORKERS)
try:
futures = {executor.submit(send_chunk_for_transcription, job): job for job in jobs}
for future in as_completed(futures):
idx, offset, segments, error = future.result()
if error:
print(f"❌ Chunk {idx} failed: {error}")
else:
print(f"✅ Chunk {idx} done: {len(segments)} segs (offset {offset:.1f}s)")
results.append((idx, offset, segments))
finally:
executor.shutdown(wait=True)
results.sort(key=lambda x: x[0])
print("📝 Writing final SRT with smart overlap dedupe...")
global_idx = 1
with open(srt_path, "w", encoding="utf-8") as srt_file:
for _, time_offset, segments in results:
for seg in segments:
actual_start = seg['start'] + time_offset
actual_end = seg['end'] + time_offset
srt_file.write(f"{global_idx}\n{format_srt_time(actual_start)} --> {format_srt_time(actual_end)}\n{seg['text'].strip()}\n\n")
global_idx += 1
print(f"\n✅ SRT saved: {srt_path} - {global_idx-1} lines")
if temp_dir.exists():
shutil.rmtree(temp_dir)
if __name__ == "__main__":
target_video = "download/videos/2813112936/2813112936.mp4"
transcribe_to_srt_remote(target_video, force=True)
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env python3
import os
import shutil
import subprocess
from pathlib import Path
import wave
from faster_whisper import WhisperModel
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
CHUNK_DURATION_SEC = 300
def extract_audio_and_chunk(video_path: str, output_dir: Path) -> list[tuple[Path, float]]:
"""Returns list of (chunk_path, start_offset_seconds)"""
print("🚀 Extracting and chunking audio with FFmpeg...")
output_dir.mkdir(parents=True, exist_ok=True)
chunk_pattern = str(output_dir / "chunk_%03d.wav")
command = [
"ffmpeg", "-y", "-i", video_path,
"-vn", "-ac", "1", "-ar", "16000",
"-acodec", "pcm_s16le",
"-f", "segment", "-segment_time", str(CHUNK_DURATION_SEC),
"-reset_timestamps", "0", # keep each chunk starting at 0
chunk_pattern
]
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if result.returncode != 0:
print(f"❌ FFmpeg Error:\n{result.stderr}")
raise RuntimeError("FFmpeg failed")
chunks = sorted(output_dir.glob("chunk_*.wav"))
# FIX: Calculate real offset from actual file durations, not idx*300
offsets = []
current_offset = 0.0
for chunk_path in chunks:
offsets.append((chunk_path, current_offset))
with wave.open(str(chunk_path), 'rb') as wf:
duration = wf.getnframes() / wf.getframerate()
current_offset += duration
return offsets
def format_srt_time(seconds: float):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def transcribe_to_srt(video_path: str, force: bool = False):
if not hasattr(transcribe_to_srt, "model"):
print("🔧 Initializing C++ Engine...")
transcribe_to_srt.model = WhisperModel(
"distil-small.en",
device="cpu",
compute_type="int8",
cpu_threads=4, # 4 = physical cores on E3-1280 v5 = fastest
num_workers=1
)
print("✅ Model loaded.")
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() and not force:
print(f"❌ SRT already exists: {srt_path}")
return
if srt_path.exists():
srt_path.unlink()
try:
audio_chunks = extract_audio_and_chunk(str(video_path_obj), temp_dir)
print(f"📦 {len(audio_chunks)} chunks.")
print("🎙 Transcribing...")
global_idx = 1
with open(srt_path, "w", encoding="utf-8") as srt_file:
for chunk_idx, (chunk_path, time_offset) in enumerate(audio_chunks):
print(f"\n⏳ Chunk {chunk_idx+1}/{len(audio_chunks)} offset={time_offset:.1f}s")
segments, info = transcribe_to_srt.model.transcribe(
str(chunk_path),
language="en",
beam_size=1,
vad_filter=True,
vad_parameters=dict(min_silence_duration_ms=500),
condition_on_previous_text=False # important for chunked mode
)
for seg in segments:
actual_start = seg.start + time_offset
actual_end = seg.end + time_offset
# Use comma for SRT, not dot
srt_file.write(f"{global_idx}\n{format_srt_time(actual_start)} --> {format_srt_time(actual_end)}\n{seg.text.strip()}\n\n")
global_idx += 1
chunk_path.unlink()
print(f"\n✅ SRT saved: {srt_path}")
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback; traceback.print_exc()
finally:
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"❌ Not found: {target_video}")
else:
transcribe_to_srt(target_video, force=True)
-15
View File
@@ -1,15 +0,0 @@
# 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
@@ -1,155 +0,0 @@
#!/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
@@ -1,24 +0,0 @@
#!/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
@@ -1,144 +0,0 @@
#!/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.Repository()
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())
-86
View File
@@ -1,86 +0,0 @@
#!/usr/bin/env python3
import subprocess
from pathlib import Path
from datetime import datetime, timedelta, timezone
import package.utils as utils
from package.config import DB as DB
from package.config import TZ as TZ
from package.database import TwitchVideo, Chapter
def download_videos():
"""Find all undownloaded videos and clips, and download them safely."""
undownloaded = DB.get_undownloaded()
print("Starting downloads...")
for row in undownloaded:
try:
# 2026-08-16T23:52:10Z -> replace Z with +00:00
data_timestamp = datetime.fromisoformat(row.created_at.replace("Z", "+00:00"))
except Exception:
data_timestamp = datetime.strptime(row.created_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - data_timestamp < timedelta(hours=24):
print(f"Skipping {row.id} - less than 24h old")
continue
print(f"ID: {row.id} | Date: {row.created_at} | Game: {row.game_name} | Title: {row.title}")
base_dir = Path(f"download/clips/{row.id}" if row.clip_is else f"download/videos/{row.id}")
base_dir.mkdir(parents=True, exist_ok=True)
temp_path = Path("download/temp")
temp_path.mkdir(parents=True, exist_ok=True)
target_video = base_dir / f"{row.id}.mp4"
json_file = base_dir / f"{row.id}_chat.json"
csv_file = base_dir / f"{row.id}.csv"
if row.clip_is:
cmd = f"TwitchDownloaderCLI clipdownload --id {row.id} -o {target_video} --collision Overwrite --temp-path {temp_path}"
else:
cmd = f"TwitchDownloaderCLI videodownload --id {row.id} -o {target_video} --collision Overwrite --threads 2 --temp-path {temp_path}"
output = utils.run_command(cmd, look_for=["[STATUS]"])
output2 = utils.run_command(
f"TwitchDownloaderCLI chatdownload --id {row.id} -o {json_file} -E --collision Overwrite --temp-path {temp_path}",
look_for=["[STATUS]"]
)
if output2 and json_file.exists():
related_id = utils.get_video_id_from_chat(json_file)
if related_id:
# This will set related_video_id = 2848295283 for a clip
DB.set_related_video(row.id, related_id)
print(f" -> Linked {row.id} -> related VOD {related_id}")
if row.clip_is:
chapters = utils.get_chapters(json_file)
for chapter in chapters:
chapter_record = Chapter(
twitch_video_id=row.id,
start_ms=chapter.get('startMilliseconds'),
length_ms=chapter.get('lengthMilliseconds'),
description=chapter.get('description'),
type=chapter.get('type'),
game_display_name=chapter.get('gameDisplayName'),
game_id=chapter.get('gameId')
)
DB.insert(chapter_record)
print(f" ➕ Chapter added: {chapter_record.description} ({chapter_record.start_ms}ms)")
if output:
# FIX 4: write_csv needs actual row data, not column names
utils.write_csv([row], str(csv_file)) # pass the object/dict, not vars(TwitchVideo)
print(f"✅ Success: {base_dir}")
DB.mark_as_downloaded(row.id)
else:
print(f"❌ ID: {row.id} | Download failed.")
if not row.clip_is:
print("Flushing temporary crash chunks...")
subprocess.run("rm -rf download/temp/*", shell=True)
print("Finished Downloading Pipeline.")
if __name__ == "__main__":
download_videos()
-179
View File
@@ -1,179 +0,0 @@
#!/usr/bin/env python3
import asyncio
import httpx # Switched from requests to prevent async loop freezing
from twitchAPI.helper import first
from twitchAPI.type import VideoType
import package.twitch.config as config
from package.config import DB as DB
from package.database.database import TwitchVideo
GAME_CACHE = {} # Local cache dictionary to store game_id -> game_name mapping
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
print(f"No game found with the exact name: '{game_name}'")
return None
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 = config.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) -> tuple:
"""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() -> list:
await config.get_twitch()
print(f"Starting VOD extraction for {config.USER.display_name}...")
vod_generator = config.TWITCH.get_videos(user_id=config.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 = TwitchVideo()
vod_data.id = v.id
vod_data.title = v.title
vod_data.created_at = str(v.published_at)
vod_data.view_count = int(v.view_count) if v.view_count else 0
vod_data.duration = v.duration
vod_data.url = v.url
vod_data.thumbnail_url = v.thumbnail_url
vod_data.game_id = clean_game_id
vod_data.game_name = game_name
vod_data.stream_id = str(v.stream_id) if v.stream_id else "0"
vod_data.creator_name = config.CHANNEL_NAME
vod_data.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() -> list:
await config.get_twitch()
print(f"Starting clip extraction for {config.USER.display_name}...")
clip_generator = config.TWITCH.get_clips(broadcaster_id=config.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 = TwitchVideo()
clip_data.id = c.id
clip_data.title = c.title
clip_data.created_at = str(c.created_at)
clip_data.view_count = int(c.view_count) if c.view_count else 0
clip_data.duration = c.duration
clip_data.url = c.url
clip_data.thumbnail_url = c.thumbnail_url
clip_data.game_id = c.game_id
clip_data.game_name = game_name
clip_data.stream_id = "0"
clip_data.creator_name = c.creator_name
clip_data.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 video_info():
print("--- Script Started ---")
vods_list = await get_streamer_vods()
for v in vods_list:
DB.insert(v)
if vods_list:
print("====================================================")
print(f"Recent VOD: '{vods_list[0].title}'")
print("====================================================")
clips_list = await get_streamer_clips()
for v in clips_list:
DB.insert(v)
if clips_list:
print("====================================================")
print(f"Recent VOD: '{clips_list[0].title}'")
print("====================================================")
if __name__ == "__main__":
asyncio.run(video_info())
-138
View File
@@ -1,138 +0,0 @@
#!/usr/bin/env python3
import csv
from pathlib import Path
import sys
import json
import shlex
import subprocess
def get_chapters(json_path):
"""Loads the JSON file and returns all chapters."""
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# The chapters are under video -> chapters
video = data.get('video', {})
chapters = video.get('chapters', [])
if not chapters:
print("No chapters found.")
return []
print(f"Found {len(chapters)} chapter(s) in video '{video.get('title')}':\n")
for i, chapter in enumerate(chapters, 1):
start_sec = chapter.get('startMilliseconds', 0) / 1000
length_sec = chapter.get('lengthMilliseconds', 0) / 1000
#print(f"{i}. {chapter.get('description', 'No Title')}")
#print(f" - Type: {chapter.get('type')}")
#print(f" - Start: {start_sec}s ({chapter.get('startMilliseconds')}ms)")
#print(f" - Length: {length_sec}s ({chapter.get('lengthMilliseconds')}ms)")
#print(f" - Game: {chapter.get('gameDisplayName')} (ID: {chapter.get('gameId')})")
#print()
return chapters
def get_video_id_from_chat(json_path: str | Path) -> str | None:
"""Gets the id inside video:{ } - from the chat json you download"""
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("video", {}).get("id")
except Exception as e:
print(f"Could not read video.id from {json_path}: {e}")
return None
def write_csv(data: list, file_name: str):
"""Writes a list of dictionaries or objects to a CSV file."""
if not data:
print(f"⚠️ Warning: No data provided to write to {file_name}.")
return
# Determine if the data contains objects that need serialization
first_item = data[0]
if hasattr(first_item, '__dict__') and not isinstance(first_item, (str, int, float, bool)):
print("ℹ️ Detected object types. Converting objects to dictionary rows for CSV writing.")
# Extract attributes/properties into a list of dictionaries
data_to_write = []
# We assume all objects in the list have the same structure for simplicity
# A more robust solution would require knowing the schema.
for item in data:
row_dict = {}
# Export all instance attributes to the dictionary
for key, value in item.__dict__.items():
row_dict[key] = value
data_to_write.append(row_dict)
# Since csv.writer doesn't handle dicts directly, we list the keys from the first dict
fieldnames = list(data_to_write[0].keys())
with open(file_name, "w", newline="", encoding="utf-8") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(data_to_write)
else:
# Assume data is already a list of lists/tuples
with open(file_name, "w", newline="", encoding="utf-8") as file:
writer = csv.writer(file)
# Write all rows at once
writer.writerows(data)
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
-13
View File
@@ -1,13 +0,0 @@
# package/video_editing/__init__.py
# Expose components from individual modules using relative imports
from .combine_videos import merge_videos
from .split_video_by_timestamps import split_video
from .build_shorts import fit_to_9_16_letterbox
# Explicitly define what is exposed when importing the subpackage
__all__ = [
"merge_videos",
"split_video",
"fit_to_9_16_letterbox"
]
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env python3
import os
import json
import package.utils as utils
import tempfile
from pathlib import Path
from moviepy.video.VideoClip import TextClip
from pathlib import Path
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.")
-120
View File
@@ -1,120 +0,0 @@
#!/usr/bin/env python3
"""Combine multiple video files into one file using FFmpeg.
Example:
python helper_files/combine_videos.py output.mp4 video1.mp4 video2.mp4 video3.mp4
python helper_files/combine_videos.py --output merged.mp4 --inputs video1.mp4 video2.mp4
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
def build_concat_list(input_files, list_path):
with open(list_path, "w", encoding="utf-8") as f:
for path in input_files:
f.write(f"file '{os.path.abspath(path)}'\n")
def run_ffmpeg(cmd):
print("Running:", " ".join(cmd))
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(result.stdout)
print(result.stderr)
raise RuntimeError(f"FFmpeg failed with exit code {result.returncode}")
print("✅ Finished successfully.")
def concat_videos_copy(input_files, output_path):
list_path = Path(output_path).with_suffix(".concat.txt")
build_concat_list(input_files, list_path)
cmd = [
"ffmpeg",
"-y",
"-f", "concat",
"-safe", "0",
"-i", str(list_path),
"-c", "copy",
str(output_path),
]
run_ffmpeg(cmd)
list_path.unlink(missing_ok=True)
def concat_videos_reencode(input_files, output_path):
inputs = []
filter_parts = []
audio_parts = []
for i, video_path in enumerate(input_files):
inputs.extend(["-i", str(video_path)])
# Use semicolons to separate different input streams in the filter graph
filter_parts.append(f"[{i}:v]scale=trunc(iw/2)*2:trunc(ih/2)*2,setpts=PTS-STARTPTS[v{i}]")
audio_parts.append(f"[{i}:a]aresample=async=1[a{i}]")
# Correctly separate individual stream filters with semicolons
filter_complex = f"{';'.join(filter_parts)};{';'.join(audio_parts)};"
filter_complex += "".join(f"[v{i}][a{i}]" for i in range(len(input_files)))
filter_complex += f"concat=n={len(input_files)}:v=1:a=1[outv][outa]"
cmd = [
"ffmpeg",
"-y",
*inputs,
"-filter_complex", filter_complex,
"-map", "[outv]",
"-map", "[outa]",
"-c:v", "libx264",
"-crf", "18", # High quality video
"-preset", "slow", # High efficiency
"-c:a", "aac",
"-b:a", "256k", # High quality audio
"-movflags", "+faststart",
str(output_path),
]
run_ffmpeg(cmd)
def merge_videos(input_files, output_path):
input_files = [str(Path(p).expanduser().resolve()) for p in input_files]
output_path = str(Path(output_path).expanduser().resolve())
if len(input_files) < 2:
raise ValueError("You need at least 2 video files to merge.")
for path in input_files:
if not os.path.exists(path):
raise FileNotFoundError(f"File not found: {path}")
output_dir = Path(output_path).parent
output_dir.mkdir(parents=True, exist_ok=True)
try:
concat_videos_copy(input_files, output_path)
except Exception as exc:
print(f"Fast copy method failed: {exc}")
print("Retrying with re-encode fallback...")
concat_videos_reencode(input_files, output_path)
def main():
parser = argparse.ArgumentParser(description="Join two or more video files together with FFmpeg.")
parser.add_argument("output_file", help="Path to the merged output video")
parser.add_argument("input_files", nargs="+", help="Two or more videos to concatenate")
args = parser.parse_args()
try:
merge_videos(args.input_files, args.output_file)
print(f"Merged video saved to: {args.output_file}")
except Exception as exc:
print(f"Error: {exc}")
sys.exit(1)
if __name__ == "__main__":
main()
-378
View File
@@ -1,378 +0,0 @@
import re
import cv2
import numpy as np
import argparse
import json
from pathlib import Path
from moviepy import VideoFileClip
# ---- CONFIG ----
DEFAULT_KEYWORDS = [
# Pure Hype - the #1 highlight signal
"pog", "poggers", "pogchamp", "let's go", "lets go", "huge", "insane", "crazy",
"unbelievable", "no way", "way too good", "oh my god", "omg", "wow", "holy",
"holy shit", "holy fuck", "what the fuck", "wtf", "lfg", "clip that", "clip it",
"someone clip", "chat clip", "world record", "pb", "personal best",
# Chat interaction = something happened
"chat", "look at chat", "chat is going crazy", "we are so back", "we're so back",
"it's over", "ggs", "gg", "not like this", "like this", "AYAYA", "KEKW",
"monka", "pepega", "copium",
# Gaming clutch moments
"clutch", "ace", "1v4", "1v3", "1v2", "quadra", "penta", "pentakill",
"triple kill", "rampage", "unstoppable", "godlike", "wipe", "team wipe",
"no scope", "collat", "one shot", "one tap", "200 pumped", "knocked", "cracked",
# Reaction / laugh / fail
"bro", "dude", "what is happening", "what just happened", "there is no way",
"you're kidding", "stop", "no no no", "yes yes yes", "i can't", "i cant believe",
"this is insane", "this guy", "look at this", "wait wait wait"
]
# You can add your own: --keywords "goal, fight, knock out"
import subprocess
import tempfile
import os
from scipy.io import wavfile
def get_av_scores(video_path, sample_fps=2):
print(f"-> Opening: {video_path}", flush=True)
if not os.path.exists(video_path):
raise FileNotFoundError(video_path)
with VideoFileClip(video_path) as clip:
duration = clip.duration
fps = clip.fps or 30
has_audio = clip.audio is not None
print(f" duration={duration:.2f}s fps={fps} has_audio={has_audio}", flush=True)
n_secs = int(duration)
# ---- 1. AUDIO (streaming, constant RAM) ----
print(f"[1/3] Audio energy...", flush=True)
audio_per_sec = np.zeros(n_secs)
if has_audio:
tmp_wav = tempfile.mktemp(suffix=".wav")
try:
print(f" extracting mono 16k wav with ffmpeg...", flush=True)
cmd = [
"ffmpeg", "-y",
"-i", video_path,
"-ar", "16000",
"-ac", "1",
"-c:a", "pcm_s16le",
tmp_wav
]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300)
if result.returncode!= 0:
print(f" ffmpeg failed: {result.stderr.decode()[:1000]}")
else:
wav_size = os.path.getsize(tmp_wav)
print(f" wav size: {wav_size} bytes, computing RMS streaming...", flush=True)
# STREAM instead of loading all at once
import wave
with wave.open(tmp_wav, 'rb') as wf:
sr = wf.getframerate() # should be 16000
n_frames = wf.getnframes()
# sr frames = 1 second
for i in range(n_secs):
if wf.tell() >= n_frames:
break
# read 1 second
frames = wf.readframes(sr)
if not frames:
break
# int16 -> float without extra copy
chunk = np.frombuffer(frames, dtype=np.int16).astype(np.float32) / 32768.0
if len(chunk) > 0:
audio_per_sec[i] = np.sqrt(np.mean(chunk**2))
if i % 300 == 0: # log every 5 min
print(f" audio {i}/{n_secs}s", flush=True)
if audio_per_sec.max() > 0:
audio_per_sec = (audio_per_sec - audio_per_sec.min()) / (audio_per_sec.max() - audio_per_sec.min() + 1e-6)
print(f" audio done", flush=True)
except Exception as e:
print(f" audio error: {e}")
import traceback; traceback.print_exc()
finally:
if os.path.exists(tmp_wav):
os.remove(tmp_wav)
else:
print(" no audio track", flush=True)
# ---- 2. MOTION ----
print(f"[2/3] Motion analysis sample_fps={sample_fps}...", flush=True)
cap = cv2.VideoCapture(video_path)
motion_scores = []
prev_gray = None
frame_interval = int(fps / sample_fps) if fps > 0 else 15
num_samples = int(duration * sample_fps)
for i in range(num_samples):
cap.set(cv2.CAP_PROP_POS_FRAMES, i * frame_interval)
ret, frame = cap.read()
if not ret:
continue
gray = cv2.cvtColor(cv2.resize(frame, (320, 180)), cv2.COLOR_BGR2GRAY)
if prev_gray is not None:
motion_scores.append(np.mean(cv2.absdiff(gray, prev_gray)) / 255.0)
else:
motion_scores.append(0)
prev_gray = gray
if i % 50 == 0:
print(f" motion {i}/{num_samples}", flush=True)
cap.release()
if len(motion_scores) > 1:
x = np.linspace(0, duration-1, len(motion_scores))
motion_per_sec = np.interp(np.arange(n_secs), x, motion_scores)
if motion_per_sec.max() > 0:
motion_per_sec = (motion_per_sec - motion_per_sec.min()) / (motion_per_sec.max() - motion_per_sec.min() + 1e-6)
else:
motion_per_sec = np.zeros(n_secs)
combined_av = 0.6 * audio_per_sec + 0.4 * motion_per_sec
return combined_av, duration
def srt_time_to_seconds(time_str):
# 00:01:23,456 or 00:01:23.456
time_str = time_str.strip().replace('.', ',')
h, m, s_ms = time_str.split(':')
s, ms = s_ms.split(',')
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
def load_text_from_srt(srt_path):
print(f"[3/3] Loading transcript from SRT: {srt_path}")
if not Path(srt_path).exists():
raise FileNotFoundError(f"SRT not found: {srt_path}")
# utf-8-sig strips BOM that Whisper sometimes adds
with open(srt_path, 'r', encoding='utf-8-sig') as f:
content = f.read().strip()
if not content:
raise ValueError(f"SRT file is empty: {srt_path}")
# Split by blank line - works for \n\n and \r\n\r\n
blocks = re.split(r'\r?\n\r?\n', content)
segments = []
# Pattern inside each block only
time_pattern = re.compile(r'(\d{2}:\d{2}:\d{2}[,\.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,\.]\d{3})')
for block in blocks:
block = block.strip()
if not block:
continue
lines = block.splitlines()
if len(lines) < 2:
continue
# Find time line (line 0 or 1 - index may be missing)
time_match = None
text_start_idx = 0
for idx, line in enumerate(lines[:3]): # check first 3 lines for time
m = time_pattern.search(line)
if m:
time_match = m
text_start_idx = idx + 1
break
if not time_match:
continue
start_str, end_str = time_match.groups()
text = ' '.join(lines[text_start_idx:]).strip()
# Clean Whisper / Twitch artifacts
text = re.sub(r'<[^>]+>', '', text) # remove <i>, <font> etc
text = text.replace('\n', ' ').strip()
if text:
segments.append({
"start": srt_time_to_seconds(start_str),
"end": srt_time_to_seconds(end_str),
"text": text
})
print(f"Loaded {len(segments)} segments from SRT")
if len(segments) == 0:
print(f"WARNING: Parsed 0 segments. First 500 chars of file:\n{content[:500]}")
return segments
def score_keywords(segments, duration, keywords):
"""Returns per-second keyword boost score + keyword hits"""
keyword_score = np.zeros(int(duration))
hits = []
# Build regex with word boundaries for each keyword
# \bace\b won't match "place", \bgg\b won't match "ggs"
patterns = []
for kw in keywords:
kw = kw.strip()
if not kw:
continue
# \b at start/end, but handle multi-word phrases
# re.escape handles "let's go", "1v4", etc
pat = re.compile(r'\b' + re.escape(kw.lower()) + r'\b', re.IGNORECASE)
patterns.append((kw.lower(), pat))
for seg in segments:
text = seg['text']
text_lower = text.lower()
for kw_lower, pat in patterns:
if pat.search(text_lower):
start = int(max(0, seg['start'] - 1))
end = int(min(duration-1, seg['end'] + 2))
keyword_score[start:end+1] += 1.0
hits.append({
"timestamp": round(seg['start'], 2),
"end": round(seg['end'], 2),
"keyword": kw_lower,
"text": text.strip()
})
break
if keyword_score.max() > 0:
keyword_score = keyword_score / keyword_score.max()
return keyword_score, hits
def find_highlights(final_scores, duration, top_n=5, clip_len=8, min_gap=12):
candidates = []
for i in range(len(final_scores) - clip_len):
candidates.append((np.mean(final_scores[i:i+clip_len]), i))
candidates.sort(reverse=True, key=lambda x: x[0])
picked = []
for score, start in candidates:
if all(abs(start - p[1]) >= min_gap for p in picked):
picked.append((score, start))
if len(picked) >= top_n:
break
picked.sort(key=lambda x: x[1])
return [(s, e, sc) for sc, s in [(sc, st) for sc, st in picked] for e in [s+clip_len]]
def format_timestamp(seconds):
m, s = divmod(int(seconds), 60)
h, m = divmod(m, 60)
return f"{h:02d}:{m:02d}:{s:02d}" if h > 0 else f"{m:02d}:{s:02d}"
def highlights(video_path, keywords=None, top_n=5, clip_len=8, min_gap=12):
av_score, duration = get_av_scores(video_path, sample_fps=0.5)
segments = load_text_from_srt(video_path.replace('.mp4', '.srt'))
kw_score, kw_hits = score_keywords(segments, duration, keywords)
# FINAL SCORE: AV + keyword boost (keyword is huge if found)
final_score = av_score + (kw_score * 1.5)
final_score = (final_score - final_score.min()) / (final_score.max() - final_score.min() + 1e-6)
final_score = np.convolve(final_score, np.ones(3)/3, mode='same') # smooth
highlights = find_highlights(final_score, duration, top_n=top_n, clip_len=clip_len)
# --- OUTPUT ---
print("\n" + "="*60)
print("KEYWORD HITS:")
for h in kw_hits:
print(f" [{format_timestamp(h['timestamp'])}] ({h['keyword']}) -> {h['text']}")
print("\n" + "="*60)
print("FINAL HIGHLIGHTS:")
output_data = []
for i, (start, end, score) in enumerate(highlights, 1):
# find transcript in that window
texts_in_window = [s['text'].strip() for s in segments if not (s['end'] < start or s['start'] > end)]
transcript = " ".join(texts_in_window)[:120]
print(f"{i}. {format_timestamp(start)} - {format_timestamp(end)} (score {score:.2f}) | {transcript}")
output_data.append({
"id": i,
"start_sec": float(start),
"end_sec": float(end),
"start_timestamp": format_timestamp(start),
"end_timestamp": format_timestamp(end),
"score": float(score),
"transcript": transcript
})
# Save timestamps to JSON
with open("highlights.json", "w") as f:
json.dump({"video": video_path, "keywords": keywords, "highlights": output_data, "keyword_hits": kw_hits}, f, indent=2)
print(f"\nTimestamps saved to highlights.json")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find highlights with Whisper keywords + timestamps")
parser.add_argument("video", help="Input video file")
parser.add_argument("--keywords", type=str, default=",".join(DEFAULT_KEYWORDS), help="Comma-separated keywords to hunt for")
parser.add_argument("--model", type=str, default="base", help="Whisper model: tiny, base, small, medium")
parser.add_argument("--top", type=int, default=5)
parser.add_argument("--len", type=int, default=30, help="Highlight length in seconds")
parser.add_argument("--no-cut", action="store_true", help="Only print timestamps, don't cut video files")
args = parser.parse_args()
keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
av_score, duration = get_av_scores(args.video, sample_fps=0.5)
segments = load_text_from_srt(args.video.replace('.mp4', '.srt'))
kw_score, kw_hits = score_keywords(segments, duration, keywords)
# FINAL SCORE: AV + keyword boost (keyword is huge if found)
final_score = av_score + (kw_score * 1.5)
final_score = (final_score - final_score.min()) / (final_score.max() - final_score.min() + 1e-6)
final_score = np.convolve(final_score, np.ones(3)/3, mode='same') # smooth
highlights = find_highlights(final_score, duration, top_n=args.top, clip_len=args.len)
# --- OUTPUT ---
print("\n" + "="*60)
print("KEYWORD HITS:")
for h in kw_hits:
print(f" [{format_timestamp(h['timestamp'])}] ({h['keyword']}) -> {h['text']}")
print("\n" + "="*60)
print("FINAL HIGHLIGHTS:")
output_data = []
for i, (start, end, score) in enumerate(highlights, 1):
# find transcript in that window
texts_in_window = [s['text'].strip() for s in segments if not (s['end'] < start or s['start'] > end)]
transcript = " ".join(texts_in_window)[:120]
print(f"{i}. {format_timestamp(start)} - {format_timestamp(end)} (score {score:.2f}) | {transcript}")
output_data.append({
"id": i,
"start_sec": float(start),
"end_sec": float(end),
"start_timestamp": format_timestamp(start),
"end_timestamp": format_timestamp(end),
"score": float(score),
"transcript": transcript
})
# Save timestamps to JSON
with open("highlights.json", "w") as f:
json.dump({"video": args.video, "keywords": keywords, "highlights": output_data, "keyword_hits": kw_hits}, f, indent=2)
print(f"\nTimestamps saved to highlights.json")
# Cut videos if requested
#if not args.no_cut:
if args.no_cut:
print("\nCutting clips...")
Path("highlights").mkdir(exist_ok=True)
clip = VideoFileClip(args.video)
for h in output_data:
sub = clip.subclip(h['start_sec'], h['end_sec'])
out = f"highlights/highlight_{h['id']}_{h['start_timestamp'].replace(':','-')}.mp4"
sub.write_videofile(out, codec="libx264", audio_codec="aac", logger=None)
clip.close()
print("Done ->./highlights/")
@@ -1,112 +0,0 @@
import subprocess
import os
from typing import List, Tuple
def split_video(input_file: str, output_dir: str, timestamps: List[Tuple[float, float]], frame_perfect: bool = False) -> None:
"""
Splits a video file into multiple segments based on specified time ranges.
Args:
input_file (str): The path to the video file to be split.
output_dir (str): The directory where the split segments will be saved.
timestamps (List[Tuple[float, float]]): A list of tuples representing (start_time, end_time).
frame_perfect (bool): If True, skips fast-copy and forces high-quality re-encoding
to ensure cuts happen exactly on the requested frame.
"""
if not os.path.exists(input_file):
print(f"Error: Input file not found at {input_file}")
return
os.makedirs(output_dir, exist_ok=True)
print(f"Starting video split process for {input_file}...")
for i, (start, end) in enumerate(timestamps):
segment_name = f"segment_{i+1:02d}_{start:.2f}s_to_{end:.2f}s.mp4"
output_file = os.path.join(output_dir, segment_name)
print(f"\n-> Processing segment {i+1}/{len(timestamps)}: {segment_name}")
# Method 1: Fast Copy (Lossless, no re-encoding)
# Moving -ss BEFORE -i fixes the frozen/corrupted initial frames.
fast_command = [
"ffmpeg", "-y",
"-ss", str(start),
"-i", input_file,
"-to", str(end),
"-c", "copy",
output_file
]
# Method 2: High-Quality Re-encode (Frame-perfect fallback)
# Used if fast copy fails or if frame-accuracy is explicitly requested.
reencode_command = [
"ffmpeg", "-y",
"-ss", str(start),
"-i", input_file,
"-to", str(end),
"-c:v", "libx264",
"-crf", "18", # High visual quality
"-preset", "slow", # Better compression efficiency
"-c:a", "aac",
"-b:a", "256k", # High-fidelity audio
output_file
]
try:
if frame_perfect:
print("Running in frame-perfect mode (re-encoding)...")
subprocess.run(reencode_command, check=True, capture_output=True, text=True)
else:
try:
print("Attempting lightning-fast stream copy...")
subprocess.run(fast_command, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError:
print("⚠️ Fast copy failed. Falling back to high-quality re-encode...")
subprocess.run(reencode_command, check=True, capture_output=True, text=True)
print(f"Successfully created segment: {output_file}")
except subprocess.CalledProcessError as e:
print(f"❌ Error splitting video segment {i+1}:")
print(f"Command failed with return code {e.returncode}")
print(f"STDERR: {e.stderr}")
except FileNotFoundError:
print("-" * 50)
print("CRITICAL ERROR: FFmpeg executable not found.")
print("Please ensure 'ffmpeg' is installed and accessible in your system's PATH.")
print("-" * 50)
return
def main():
"""
Example usage:
Assumes 'input.mp4' exists and a 'split_videos' directory is ready.
"""
# --- CONFIGURATION ---
INPUT_VIDEO_PATH = "path/to/your/input_video.mp4" # <<< CHANGE THIS
OUTPUT_SEGMENTS_DIR = "output_splits" # <<< CHANGE THIS
# Define time segments: [(start_time, end_time), ...]
# Times should be floats representing seconds.
# Example 1: 0s to 10s, then 10s to 20s
TIMESTAMPS: List[Tuple[float, float]] = [
(0.0, 10.0),
(15.0, 25.0),
(30.0, 35.0)
]
# ---------------------
if not os.path.exists(INPUT_VIDEO_PATH):
print("!!! IMPORTANT !!!")
print(f"Please update the 'INPUT_VIDEO_PATH' variable in the main() function with the correct path to your video.")
print("!!! Script execution aborted. !!!")
return
split_video(
input_file=INPUT_VIDEO_PATH,
output_dir=OUTPUT_SEGMENTS_DIR,
timestamps=TIMESTAMPS
)
if __name__ == "__main__":
main()
-13
View File
@@ -1,13 +0,0 @@
# package/youtube/__init__.py
# Expose components from individual modules using relative imports
from .find_tags import find_tags
from .uploader import upload_video, CategoryId
# Explicitly define what is exposed when importing the subpackage
__all__ = [
"find_tags",
"get_top_nouns",
"upload_video",
"CategoryId",
]
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env python3
import json
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
def get_youtube_service():
"""Get the YouTube service using the loaded credentials."""
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())
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
if credentials is None:
print("Error: Credentials not loaded. Please check secrets.json.")
return None
try:
youtube_service = build('youtube', 'v3', credentials=credentials)
return youtube_service
except Exception as e:
print(f"Error creating YouTube service: {str(e)}")
return None
+80 -56
View File
@@ -1,72 +1,96 @@
aiohappyeyeballs==2.7.1
aiohttp==3.14.3
aiosignal==1.4.0
anyio==4.15.1
attrs==26.1.0
av==18.1.0
certifi==2026.7.22
cffi==2.1.1
charset-normalizer==3.5.1
click==8.5.0
cloudpickle==3.1.2
cryptography==50.0.1
ctranslate2==4.8.2
beautifulsoup4==4.14.3
certifi==2026.2.25
cffi==2.0.0
chardet==5.2.0
charset-normalizer==3.4.7
click==8.4.2
colorama==0.4.6
cryptography==49.0.0
cuda-bindings==13.3.1
cuda-pathfinder==1.5.6
cuda-toolkit==13.0.3.0
decorator==5.3.1
defusedxml==0.7.1
enum-tools==0.13.0
faster-whisper==1.2.1
filelock==3.32.6
flatbuffers==25.12.19
frozenlist==1.8.0
fsspec==2026.7.0
google-api-core==2.36.0
google-api-python-client==2.200.0
google-auth==2.58.0
google-auth-httplib2==0.4.2
google-auth-oauthlib==1.4.1
googleapis-common-protos==1.75.3
greenlet==3.5.6
docopt==0.6.2
filelock==3.31.0
fsspec==2026.6.0
google-api-core==2.31.0
google-api-python-client==2.198.0
google-auth==2.56.0
google-auth-httplib2==0.4.0
google-auth-oauthlib==1.4.0
googleapis-common-protos==1.75.0
h11==0.16.0
hf-xet==1.6.0
httpcore==1.0.9
httplib2==0.32.0
httpx==0.28.1
huggingface_hub==1.31.0
idna==3.19
ImageIO==2.37.4
idna==3.11
ImageIO==2.37.3
imageio-ffmpeg==0.6.0
joblib==1.6.0
Jinja2==3.1.6
joblib==1.5.3
llvmlite==0.48.0
MarkupSafe==3.0.3
more-itertools==11.1.0
moviepy==2.2.1
multidict==6.8.0
nltk==3.10.3
numpy==2.5.3
mpmath==1.3.0
networkx==3.6.1
nltk==3.10.0
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
onnxruntime==1.30.0
opencv-python==5.0.0.93
opentelemetry-api==1.44.0
packaging==26.3
openai-whisper==20250625
outcome==1.3.0.post0
packaging==26.2
pillow==11.3.0
pipreqs==0.4.13
proglog==0.1.12
propcache==0.5.2
proto-plus==1.28.4
protobuf==7.36.1
proto-plus==1.28.1
protobuf==7.35.1
pyasn1==0.6.4
pyasn1_modules==0.4.2
pycparser==3.0
Pygments==2.21.0
pyparsing==3.3.2
python-dateutil==2.9.0.post0
python-dotenv==1.2.3
PyYAML==6.0.3
regex==2026.9.10
requests==2.34.2
PySocks==1.7.1
python-apt==3.0.0
python-debian==1.0.1
python-debianbts==4.1.1
python-dotenv==1.2.2
regex==2026.7.19
reportbug==13.2.0
requests==2.33.1
requests-oauthlib==2.0.0
six==1.17.0
SQLAlchemy==2.0.54
tokenizers==0.23.2
tqdm==4.70.1
twitchAPI==4.5.0
typing_extensions==4.16.0
selenium==4.43.0
setuptools==83.0.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.8.3
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
urllib3==2.7.0
yarl==1.24.5
urllib3==2.6.3
webdriver-manager==4.0.2
websocket-client==1.9.0
wheel==0.46.1
wsproto==1.3.2
yarg==0.1.10
+61
View File
@@ -0,0 +1,61 @@
#!/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)
+12 -12
View File
@@ -1,16 +1,16 @@
#!/usr/bin/env python3
import requests
import os
import package.utils as linux
import linux
import time
from package.database import Repository
from database import Database
import package.youtube.uploader as uploader
from package.youtube.uploader import CategoryId
import uploader
from uploader import CategoryId
import package.hash_tags as hash_tags
import package.transcribe_video as transcribe_video
import youtube_hashtags
import transcribe_video
CHANNEL_NAME = 'teampgp' # Replace with the streamer's username
DB = None
@@ -43,7 +43,7 @@ def top_hashtags(id: str):
print(f"Transcribe file not found {file_srt}")
return []
tags = hash_tags.get_top_hashtags(file_srt)
tags = youtube_hashtags.get_top_hashtags(file_srt)
return tags
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")
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)
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")
DB.mark_as_downloaded(record_id)
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":
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")
time.sleep(1)
if output['success'] is True:
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"Slug: {slug} | Download Process failed. {output["stdout"]}. Error: {output["stderr"]}")
print(f"Finished Downloading {DB.table}...")
@@ -319,7 +319,7 @@ def get_vod_ids_simplified(channel_name: str):
if __name__ == "__main__":
tables = ["clips"]
for table in tables:
DB = Repository(table)
DB = Database(table)
get_vod_ids_simplified(CHANNEL_NAME)
#download()
@@ -3,13 +3,13 @@ import requests
import subprocess
from datetime import datetime
from package.database import Repository
from database import Database
import package.youtube.uploader as uploader
from package.youtube.uploader import CategoryId
import uploader
from uploader import CategoryId
CHANNEL_NAME = 'teampgp' # Replace with the streamer's username
DB = Repository("clips")
DB = Database("clips")
def run_linux_command(command: str):
"""Executes a Linux command, waits for completion, and returns output."""
@@ -35,7 +35,7 @@ def transcribe(slug: str):
print(f"video already transcribed:")
return True
import package.transcribe_video as transcribe_video
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}")
@@ -49,9 +49,9 @@ def top_hashtags(slug: str):
print(f"Transcribe file not found {file_srt}")
return []
import package.hash_tags as hash_tags
import youtube_hashtags
return hash_tags.get_top_hashtags(file_srt)
return youtube_hashtags.get_top_hashtags(file_srt)
def download_clips():
@@ -66,11 +66,11 @@ def download_clips():
# Uses standard clipdownload directive
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.")
DB.mark_as_downloaded(slug)
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...")
@@ -94,7 +94,7 @@ def upload_clips():
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:
print(f"Slug: {slug} | Was successfully uploaded.")
View File
File renamed without changes.
+5
View File
@@ -0,0 +1,5 @@
{
"client_id": "yr610ucde5vlae3zqniv23eps4ky7j",
"client_secret": "1m8bopo5hwtnv0mox9wql8i33fqrgr",
"manual_token": "dkmhv4f6k0mr1yyzof54xqll5pf7l3"
}
+171
View File
@@ -0,0 +1,171 @@
import asyncio
import json
import requests
import database
from twitchAPI.twitch import Twitch
from twitchAPI.helper import first
# Import the explicit VideoType Enum to prevent the AttributeError
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
global TWITCH
global 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) -> str:
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": vod_id
},
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "45111672eea2e507f8ba44d101a61862f9c56b11dee09a15634cb75cb9b9084d"
}
}
}]
response = requests.post(url, headers=headers, json=payload)
data = response.json()
# Parsing the Game ID out of the response array
video_info = data[0]['data']['video']
if video_info and video_info.get('game'):
game_id = video_info['game']['id']
game_name = video_info['game']['displayName']
print(f"Game: {game_name} (ID: {game_id})")
else:
print("No game information found for this VOD.")
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:
# FIXED: Resolving game category using the automatic stream markers
game_id, game_name = await get_vod_game_name(v.id)
vod_data = {
"id": v.id,
"title": v.title,
"created_at": str(v.published_at),
"view_count": int(v.view_count),
"duration": v.duration,
"url": v.url,
"thumbnail_url": v.thumbnail_url,
"game_id": int(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:
# Clips DO have game_id attributes natively supported
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),
"duration": c.duration,
"url": c.url,
"thumbnail_url": c.thumbnail_url,
"game_id": int(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 ---")
# 1. Pull clips (with categories)
#clips_list = await get_streamer_clips()
#print(f"\nSuccessfully received a list of {len(clips_list)} clips in main().")
#if clips_list:
# print(f"Top clip: '{clips_list[0]['title']}' (Game: {clips_list[0]['game_name']})")
db = database.Database()
# 2. Pull VODs (without categories)
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'])
#print(f"\nSuccessfully received a list of {len(vods_list)} VODs in main().")
if vods_list:
print(f"Recent VOD: '{vods_list[0]['title']}'")
if __name__ == "__main__":
asyncio.run(main())
+45 -17
View File
@@ -1,15 +1,16 @@
#!/usr/bin/env python3
import os
import json
import argparse
from enum import Enum
from datetime import datetime, timezone
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.http import MediaFileUpload
from googleapiclient.errors import HttpError
import package.youtube.config as config
class CategoryId(Enum):
"""Official YouTube Category IDs for API Uploads."""
@@ -29,7 +30,34 @@ class CategoryId(Enum):
SCIENCE_AND_TECHNOLOGY = "28"
NONPROFITS_AND_ACTIVISM = "29"
def upload_video(file_path: str, title: str, category: CategoryId, description: str = "", privacyStatus: str = 'private', tags: list = None, release_time: datetime = None) -> str | None:
def load_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
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
@@ -43,8 +71,6 @@ def upload_video(file_path: str, title: str, category: CategoryId, description:
release_time (datetime): Optional timezone-aware UTC datetime object for timed release
schedule_date = datetime.now(timezone.utc) + timedelta(days=2)
Returns:
a String(str) of with the youtube video id or None if somthing fails
"""
if tags is None:
tags = []
@@ -52,12 +78,16 @@ def upload_video(file_path: str, title: str, category: CategoryId, description:
try:
# Check if file exists
if not os.path.exists(file_path):
print(f"❌ Error: File not found: {file_path}")
return None
print(f"Error: File not found: {file_path}")
return False
# Load credentials
credentials = load_credentials()
if not credentials:
return False
# Create YouTube API client
youtube = config.get_youtube_service()
youtube = build('youtube', 'v3', credentials=credentials)
# Configure the status object dynamically
status_body = {
@@ -70,8 +100,7 @@ def upload_video(file_path: str, title: str, category: CategoryId, description:
# If release_time is passed, YouTube forces privacyStatus to 'private'
status_body['privacyStatus'] = 'private'
#status_body['publishAt'] = release_time.strftime('%Y-%m-%dT%H:%M:%S.000Z')
status_body['publishAt'] = release_time.isoformat()
status_body['publishAt'] = release_time.strftime('%Y-%m-%dT%H:%M:%S.000Z')
print(f"Configuring timed release for: {status_body['publishAt']}")
else:
# Standard immediate upload
@@ -121,14 +150,14 @@ def upload_video(file_path: str, title: str, category: CategoryId, description:
else:
print(f"Current Privacy Status: {response['status']['privacyStatus']}")
return response['id']
return True
except HttpError as e:
print(f"An HTTP error occurred: {str(e)}")
return None
return False
except Exception as e:
print(f"An error occurred: {str(e)}")
return None
return False
def main():
parser = argparse.ArgumentParser(description='Upload a video to YouTube')
@@ -168,6 +197,5 @@ def main():
tags=parsed_tags,
release_time=release_datetime
)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -183,7 +183,7 @@ def get_clip_slugs(channel_name):
return clip_slugs
except Exception as e:
print(f"❌ An unexpected error occurred: {e}")
print(f"An unexpected error occurred: {e}")
return []
if __name__ == "__main__":
Executable → Regular
View File
File renamed without changes.
+50
View File
@@ -0,0 +1,50 @@
#!/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
@@ -0,0 +1,152 @@
#!/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()