Add support for live chat downloading and VOD timeout configuration

This commit is contained in:
MaddoScientisto 2026-02-09 21:39:12 +01:00
commit 07856196dd
2 changed files with 284 additions and 8 deletions

View file

@ -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",

View file

@ -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}')