""" 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, video_duration: Optional[float] = None) -> 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 video_duration: Optional video duration in seconds to trim chat to match 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' ] # Trim chat to match video duration if provided if video_duration is not None and video_duration > 0: # Format duration as seconds with 1 decimal place duration_str = f'{video_duration:.1f}s' chat_settings.extend(['-e', duration_str]) print(f'{Fore.CYAN} Trimming chat to match video duration: {duration_str}{Style.RESET_ALL}') # 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, video_duration: Optional[float] = None) -> 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 video_duration: Optional video duration in seconds to trim chat to match 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 with optional duration trimming return self.render_chat(json_path, video_path, output_args, video_duration=video_duration) 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