From 07856196ddcc95521ed20bbdfb25bf5b37c1b4f1 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Mon, 9 Feb 2026 21:39:12 +0100 Subject: [PATCH] Add support for live chat downloading and VOD timeout configuration --- config.sample.json | 8 +- twitch-archive.py | 284 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 284 insertions(+), 8 deletions(-) diff --git a/config.sample.json b/config.sample.json index 4961184..0c2b68b 100644 --- a/config.sample.json +++ b/config.sample.json @@ -29,7 +29,13 @@ "_downloadVOD_comment": "0 = disable, 1 = enable VOD downloading after stream finished", "downloadCHAT": 1, - "_downloadCHAT_comment": "0 = disable, 1 = enable chat downloading and rendering", + "_downloadCHAT_comment": "0 = disable, 1 = enable chat downloading and rendering from VOD (after stream ends)", + + "downloadLiveCHAT": 1, + "_downloadLiveCHAT_comment": "0 = disable, 1 = enable downloading chat during live stream (useful if VODs are disabled)", + + "vodTimeout": 300, + "_vodTimeout_comment": "Seconds to wait for VOD to appear after stream ends (set to 0 to skip VOD check entirely, useful if streamer has VODs disabled)", "uploadCloud": 1, "_uploadCloud_comment": "0 = disable, 1 = enable upload to remote cloud", diff --git a/twitch-archive.py b/twitch-archive.py index 1f66e0d..ed354e5 100644 --- a/twitch-archive.py +++ b/twitch-archive.py @@ -65,6 +65,8 @@ DEFAULT_CONFIG = { 'downloadMETADATA': 1, 'downloadVOD': 1, 'downloadCHAT': 1, + 'downloadLiveCHAT': 1, + 'vodTimeout': 300, 'uploadCloud': 1, 'deleteFiles': 0, 'onlyRaw': 0, @@ -786,6 +788,218 @@ class TwitchArchive: self.send_notification('Chat Download Error', f'Failed to download/render chat: {str(e)}') return False + + def _download_live_chat(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 self.downloadLiveCHAT != 1: + 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:] + + downloader = self._get_twitch_downloader_executable() + + try: + # Start chat download as background process + cmd = [ + downloader, '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) -> 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 + + Returns: + bool: True if succeeded, False otherwise + """ + if self.downloadCHAT != 1: + return False + + print(f'\n{Fore.CYAN}Downloading chat: {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:] + + bin_path = self._get_bin_path() + downloader = self._get_twitch_downloader_executable() + + # 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._get_ffmpeg_executable(), + '--temp-path', os.path.join(bin_path, 'temp'), + '--collision', 'Rename' + ] + + try: + # Download chat JSON + print(f'{Fore.YELLOW}Downloading chat JSON for VOD {vod_id}...{Style.RESET_ALL}') + result = subprocess.call([ + downloader, '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 + + # Verify JSON file was created + 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}') + + # Render chat video + print(f'{Fore.YELLOW}Rendering chat video...{Style.RESET_ALL}') + result = subprocess.call([ + downloader, 'chatrender', + '-i', json_path, + '-o', video_path + ] + chat_settings) + + 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 processing failed: {str(e)}{Style.RESET_ALL}') + self.send_notification('Chat Download Error', + f'Failed to download/render chat: {str(e)}') + return False + + def _wait_for_chat_download(self, process: Optional[subprocess.Popen], json_path: str) -> 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 + + 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=300) # 5 minute 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 + + def _render_chat(self, json_path: str, video_path: str) -> bool: + """ + Render chat JSON as a video. + + Args: + json_path: Path to chat JSON file + video_path: Path to save rendered chat video + + 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 + + bin_path = self._get_bin_path() + downloader = self._get_twitch_downloader_executable() + + # 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._get_ffmpeg_executable(), + '--temp-path', os.path.join(bin_path, 'temp'), + '--collision', 'Rename' + ] + + try: + print(f'{Fore.YELLOW}Rendering chat video...{Style.RESET_ALL}') + result = subprocess.call([ + downloader, 'chatrender', + '-i', json_path, + '-o', video_path + ] + chat_settings) + + 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 _save_metadata(self, vod_info: Dict[str, Any], filename_base: str) -> None: """ @@ -926,6 +1140,17 @@ class TwitchArchive: 'live_proc_path': live_proc_path } + # Start live chat download if enabled and VOD ID is available + live_chat_process = None + chat_json_path = os.path.join(self.chatJSON_path, f"{PREFIX_CHAT}{filename_base}.json") + + if self.downloadLiveCHAT == 1 and is_live.get('archiveVideo') and is_live['archiveVideo'].get('id'): + live_vod_id = is_live['archiveVideo']['id'] + print(f'{Fore.CYAN}Live VOD ID detected: {live_vod_id}{Style.RESET_ALL}') + live_chat_process = self._download_live_chat(live_vod_id, chat_json_path) + elif self.downloadLiveCHAT == 1: + print(f'{Fore.YELLOW}⚠ No VOD ID available yet for live chat download{Style.RESET_ALL}') + # Record the live stream recording_completed = self._record_livestream(is_live, live_raw_path) @@ -936,13 +1161,56 @@ class TwitchArchive: # Process the raw stream file self._process_raw_stream(live_raw_path, live_proc_path) - # Skip VOD/chat download if shutdown was requested + # Wait for live chat download if it was started + live_chat_downloaded = False + if live_chat_process is not None: + live_chat_downloaded = self._wait_for_chat_download(live_chat_process, chat_json_path) + + # Render live chat if downloaded successfully + if live_chat_downloaded: + chat_video_path = os.path.join(self.chatMP4_path, f"{PREFIX_CHAT}{filename_base}.mp4") + self._render_chat(chat_json_path, chat_video_path) + + # Skip VOD/chat download if shutdown was requested or vodTimeout is 0 vod_response = None if self.shutdown_requested: print(f'{Fore.YELLOW}Skipping VOD and chat download due to shutdown request{Style.RESET_ALL}') + elif self.vodTimeout == 0: + print(f'{Fore.CYAN}VOD check disabled (vodTimeout=0). Skipping VOD download.{Style.RESET_ALL}') else: - # Try to match stream with VOD - vod_response = self._get_latest_vod() + # Try to match stream with VOD (with timeout) + print(f'{Fore.CYAN}Waiting for VOD to become available (timeout: {self.vodTimeout}s)...{Style.RESET_ALL}') + vod_found = False + vod_wait_start = time.time() + + while time.time() - vod_wait_start < self.vodTimeout and not self.shutdown_requested: + vod_response = self._get_latest_vod() + + if vod_response and vod_response['data']['user']['videos']['edges']: + current_vod = vod_response['data']['user']['videos']['edges'][0]['node'] + vod_date = datetime.strptime( + current_vod["recordedAt"], '%Y-%m-%dT%H:%M:%SZ' + ).replace(tzinfo=timezone('UTC')).astimezone(tz=None).replace(tzinfo=None) + + # Check if VOD matches the stream (within 1 minute tolerance) + time_tolerance = timedelta(minutes=1) + if (live_date - time_tolerance) <= vod_date <= (live_date + time_tolerance): + vod_found = True + break + + # Wait before checking again + if not vod_found: + print(f'{Fore.CYAN}VOD not found yet, waiting...{Style.RESET_ALL}', end='\r') + if not self._interruptible_sleep(min(10, self.vodTimeout - (time.time() - vod_wait_start))): + break + + if not vod_found: + if self.shutdown_requested: + print(f'\n{Fore.YELLOW}VOD check interrupted by shutdown{Style.RESET_ALL}') + else: + print(f'\n{Fore.YELLOW}⚠ VOD not found after {self.vodTimeout}s - streamer may have VODs disabled{Style.RESET_ALL}') + print(f'{Fore.CYAN} → Live recording and chat (if enabled) were saved successfully{Style.RESET_ALL}') + vod_response = None if not self.shutdown_requested and vod_response and vod_response['data']['user']['videos']['edges']: current_vod = vod_response['data']['user']['videos']['edges'][0]['node'] @@ -963,10 +1231,12 @@ class TwitchArchive: vod_path = os.path.join(self.video_path, f"{PREFIX_VOD}{filename_base}{vod_ext}") self._download_vod(current_vod, vod_path) - # Download and render chat - chat_json_path = os.path.join(self.chatJSON_path, f"{PREFIX_CHAT}{filename_base}.json") - chat_video_path = os.path.join(self.chatMP4_path, f"{PREFIX_CHAT}{filename_base}.mp4") - self._download_and_render_chat(current_vod, chat_json_path, chat_video_path) + # Download and render chat from VOD (if not already done via live chat) + if not live_chat_downloaded: + chat_video_path = os.path.join(self.chatMP4_path, f"{PREFIX_CHAT}{filename_base}.mp4") + self._download_and_render_chat(current_vod, chat_json_path, chat_video_path) + else: + print(f'{Fore.CYAN}Chat already downloaded from live stream, skipping VOD chat download{Style.RESET_ALL}') else: print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}')