293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""
|
|
VOD and chat downloading functionality using TwitchDownloaderCLI.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
from typing import Dict, Any, Optional
|
|
from colorama import Fore, Style
|
|
|
|
from .utils import get_bin_path
|
|
|
|
|
|
class ContentDownloader:
|
|
"""Handles VOD and chat downloading using TwitchDownloaderCLI."""
|
|
|
|
def __init__(self, twitch_downloader_path: str, ffmpeg_path: str, config: dict):
|
|
"""
|
|
Initialize the content downloader.
|
|
|
|
Args:
|
|
twitch_downloader_path: Path to TwitchDownloaderCLI executable
|
|
ffmpeg_path: Path to FFmpeg executable
|
|
config: Configuration dictionary
|
|
"""
|
|
self.twitch_downloader_path = twitch_downloader_path
|
|
self.ffmpeg_path = ffmpeg_path
|
|
self.quality = config.get('quality', 'best')
|
|
self.hls_segments_vod = config.get('hls_segmentsVOD', 10)
|
|
self.download_vod = config.get('downloadVOD', True)
|
|
self.download_chat = config.get('downloadCHAT', True)
|
|
self.download_live_chat = config.get('downloadLiveCHAT', True)
|
|
|
|
def download_vod(self, vod_info: Dict[str, Any], output_path: str) -> bool:
|
|
"""
|
|
Download VOD using TwitchDownloaderCLI.
|
|
|
|
Args:
|
|
vod_info: VOD metadata from Twitch API
|
|
output_path: Path where the VOD will be saved
|
|
|
|
Returns:
|
|
bool: True if download succeeded, False otherwise
|
|
"""
|
|
if not self.download_vod:
|
|
return False
|
|
|
|
print(f'\n{Fore.CYAN}Downloading VOD: {vod_info["title"]}{Style.RESET_ALL}')
|
|
|
|
# Extract numeric VOD ID
|
|
vod_id = vod_info["id"]
|
|
if isinstance(vod_id, str) and vod_id.startswith('v'):
|
|
vod_id = vod_id[1:]
|
|
|
|
vod_url = f"https://www.twitch.tv/videos/{vod_id}"
|
|
print(f'{Fore.YELLOW}VOD URL: {vod_url}{Style.RESET_ALL}')
|
|
|
|
bin_path = get_bin_path()
|
|
cmd = [
|
|
self.twitch_downloader_path,
|
|
'videodownload',
|
|
'-u', vod_url,
|
|
'-q', self.quality,
|
|
'-t', str(self.hls_segments_vod),
|
|
'--ffmpeg-path', self.ffmpeg_path,
|
|
'--temp-path', os.path.join(bin_path, 'temp'),
|
|
'--collision', 'Rename',
|
|
'-o', output_path
|
|
]
|
|
|
|
try:
|
|
result = subprocess.call(cmd)
|
|
if result == 0:
|
|
print(f'{Fore.GREEN}✓ VOD downloaded{Style.RESET_ALL}')
|
|
return True
|
|
else:
|
|
print(f'{Fore.RED}✗ VOD download failed with exit code: {result}{Style.RESET_ALL}')
|
|
return False
|
|
except Exception as e:
|
|
print(f'{Fore.RED}✗ VOD download failed: {str(e)}{Style.RESET_ALL}')
|
|
return False
|
|
|
|
def download_chat_json(self, vod_id: str, json_path: str) -> bool:
|
|
"""
|
|
Download chat JSON for a VOD.
|
|
|
|
Args:
|
|
vod_id: VOD ID
|
|
json_path: Path to save chat JSON
|
|
|
|
Returns:
|
|
bool: True if succeeded, False otherwise
|
|
"""
|
|
# Remove 'v' prefix if present
|
|
if isinstance(vod_id, str) and vod_id.startswith('v'):
|
|
vod_id = vod_id[1:]
|
|
|
|
print(f'{Fore.YELLOW}Downloading chat JSON for VOD {vod_id}...{Style.RESET_ALL}')
|
|
|
|
try:
|
|
result = subprocess.call([
|
|
self.twitch_downloader_path, 'chatdownload',
|
|
'--id', vod_id,
|
|
'--embed-images',
|
|
'--collision', 'Rename',
|
|
'-o', json_path
|
|
])
|
|
|
|
if result != 0:
|
|
print(f'{Fore.RED}✗ Chat JSON download failed with exit code: {result}{Style.RESET_ALL}')
|
|
return False
|
|
|
|
if not os.path.exists(json_path):
|
|
print(f'{Fore.RED}✗ Chat JSON file was not created{Style.RESET_ALL}')
|
|
return False
|
|
|
|
print(f'{Fore.GREEN}✓ Chat JSON downloaded{Style.RESET_ALL}')
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f'{Fore.RED}✗ Chat download failed: {str(e)}{Style.RESET_ALL}')
|
|
return False
|
|
|
|
def render_chat(self, json_path: str, video_path: str, output_args: str) -> bool:
|
|
"""
|
|
Render chat JSON as a video.
|
|
|
|
Args:
|
|
json_path: Path to chat JSON file
|
|
video_path: Path to save rendered chat video
|
|
output_args: FFmpeg output arguments for encoding
|
|
|
|
Returns:
|
|
bool: True if succeeded, False otherwise
|
|
"""
|
|
if not os.path.exists(json_path):
|
|
print(f'{Fore.RED}✗ Chat JSON file not found: {json_path}{Style.RESET_ALL}')
|
|
return False
|
|
|
|
# Validate JSON file has content (check if file size is reasonable)
|
|
try:
|
|
file_size = os.path.getsize(json_path)
|
|
if file_size < 100: # Less than 100 bytes means likely empty or invalid
|
|
print(f'{Fore.RED}✗ Chat JSON file is too small or incomplete ({file_size} bytes){Style.RESET_ALL}')
|
|
print(f'{Fore.YELLOW} This can happen when stream recording is interrupted{Style.RESET_ALL}')
|
|
return False
|
|
except Exception as e:
|
|
print(f'{Fore.RED}✗ Error checking chat JSON file: {str(e)}{Style.RESET_ALL}')
|
|
return False
|
|
|
|
bin_path = get_bin_path()
|
|
|
|
# Chat rendering settings
|
|
chat_settings = [
|
|
'--background-color', '#FF111111',
|
|
'-w', '500',
|
|
'-h', '1080',
|
|
'--outline',
|
|
'-f', 'Arial',
|
|
'--font-size', '22',
|
|
'--update-rate', '1.0',
|
|
'--offline',
|
|
'--ffmpeg-path', self.ffmpeg_path,
|
|
'--temp-path', os.path.join(bin_path, 'temp'),
|
|
'--collision', 'Rename'
|
|
]
|
|
|
|
# Add output args using = syntax to avoid parsing issues
|
|
if output_args:
|
|
chat_settings.append(f'--output-args={output_args}')
|
|
|
|
try:
|
|
print(f'{Fore.YELLOW}Rendering chat video...{Style.RESET_ALL}')
|
|
|
|
# Build complete command
|
|
full_cmd = [self.twitch_downloader_path, 'chatrender', '-i', json_path, '-o', video_path] + chat_settings
|
|
|
|
result = subprocess.call(full_cmd)
|
|
|
|
if result != 0:
|
|
print(f'{Fore.RED}✗ Chat render failed with exit code: {result}{Style.RESET_ALL}')
|
|
return False
|
|
|
|
print(f'{Fore.GREEN}✓ Chat rendered{Style.RESET_ALL}')
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f'{Fore.RED}✗ Chat rendering failed: {str(e)}{Style.RESET_ALL}')
|
|
return False
|
|
|
|
def download_and_render_chat(self, vod_info: Dict[str, Any], json_path: str,
|
|
video_path: str, output_args: str) -> bool:
|
|
"""
|
|
Download chat logs and render them as video.
|
|
|
|
Args:
|
|
vod_info: VOD metadata from Twitch API
|
|
json_path: Path to save chat JSON
|
|
video_path: Path to save rendered chat video
|
|
output_args: FFmpeg output arguments for encoding
|
|
|
|
Returns:
|
|
bool: True if succeeded, False otherwise
|
|
"""
|
|
if not self.download_chat:
|
|
return False
|
|
|
|
print(f'\n{Fore.CYAN}Downloading chat: {vod_info["title"]}{Style.RESET_ALL}')
|
|
|
|
# Extract numeric VOD ID
|
|
vod_id = vod_info["id"]
|
|
|
|
# Download chat JSON
|
|
if not self.download_chat_json(vod_id, json_path):
|
|
return False
|
|
|
|
# Render chat video
|
|
return self.render_chat(json_path, video_path, output_args)
|
|
|
|
def start_live_chat_download(self, vod_id: str, json_path: str) -> Optional[subprocess.Popen]:
|
|
"""
|
|
Start downloading live chat in the background while stream is recording.
|
|
|
|
Args:
|
|
vod_id: The VOD/stream ID to download chat from
|
|
json_path: Path to save chat JSON
|
|
|
|
Returns:
|
|
subprocess.Popen: The process handle, or None if failed to start
|
|
"""
|
|
if not self.download_live_chat:
|
|
return None
|
|
|
|
print(f'\n{Fore.CYAN}Starting live chat download...{Style.RESET_ALL}')
|
|
|
|
# Remove 'v' prefix if present
|
|
if isinstance(vod_id, str) and vod_id.startswith('v'):
|
|
vod_id = vod_id[1:]
|
|
|
|
try:
|
|
cmd = [
|
|
self.twitch_downloader_path, 'chatdownload',
|
|
'--id', vod_id,
|
|
'--embed-images',
|
|
'--collision', 'Rename',
|
|
'-o', json_path
|
|
]
|
|
|
|
print(f'{Fore.YELLOW}Live chat download started in background for VOD {vod_id}{Style.RESET_ALL}')
|
|
process = subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL
|
|
)
|
|
return process
|
|
|
|
except Exception as e:
|
|
print(f'{Fore.RED}✗ Failed to start live chat download: {str(e)}{Style.RESET_ALL}')
|
|
return None
|
|
|
|
def wait_for_chat_download(self, process: Optional[subprocess.Popen],
|
|
json_path: str, timeout: int = 300) -> bool:
|
|
"""
|
|
Wait for live chat download process to complete.
|
|
|
|
Args:
|
|
process: The chat download process handle
|
|
json_path: Path where chat JSON should be saved
|
|
timeout: Maximum time to wait in seconds
|
|
|
|
Returns:
|
|
bool: True if chat download succeeded, False otherwise
|
|
"""
|
|
if process is None:
|
|
return False
|
|
|
|
try:
|
|
print(f'{Fore.YELLOW}Waiting for live chat download to complete...{Style.RESET_ALL}')
|
|
return_code = process.wait(timeout=timeout)
|
|
|
|
if return_code == 0 and os.path.exists(json_path):
|
|
print(f'{Fore.GREEN}✓ Live chat JSON downloaded{Style.RESET_ALL}')
|
|
return True
|
|
else:
|
|
print(f'{Fore.RED}✗ Live chat download failed (exit code: {return_code}){Style.RESET_ALL}')
|
|
return False
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print(f'{Fore.YELLOW}⚠ Live chat download timed out, terminating...{Style.RESET_ALL}')
|
|
process.terminate()
|
|
return False
|
|
except Exception as e:
|
|
print(f'{Fore.RED}✗ Error waiting for chat download: {str(e)}{Style.RESET_ALL}')
|
|
return False
|