TwitchDownloader/modules/downloader.py

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