Add support for live chat downloading and VOD timeout configuration
This commit is contained in:
parent
80255b2012
commit
07856196dd
2 changed files with 284 additions and 8 deletions
|
|
@ -29,7 +29,13 @@
|
||||||
"_downloadVOD_comment": "0 = disable, 1 = enable VOD downloading after stream finished",
|
"_downloadVOD_comment": "0 = disable, 1 = enable VOD downloading after stream finished",
|
||||||
|
|
||||||
"downloadCHAT": 1,
|
"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": 1,
|
||||||
"_uploadCloud_comment": "0 = disable, 1 = enable upload to remote cloud",
|
"_uploadCloud_comment": "0 = disable, 1 = enable upload to remote cloud",
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ DEFAULT_CONFIG = {
|
||||||
'downloadMETADATA': 1,
|
'downloadMETADATA': 1,
|
||||||
'downloadVOD': 1,
|
'downloadVOD': 1,
|
||||||
'downloadCHAT': 1,
|
'downloadCHAT': 1,
|
||||||
|
'downloadLiveCHAT': 1,
|
||||||
|
'vodTimeout': 300,
|
||||||
'uploadCloud': 1,
|
'uploadCloud': 1,
|
||||||
'deleteFiles': 0,
|
'deleteFiles': 0,
|
||||||
'onlyRaw': 0,
|
'onlyRaw': 0,
|
||||||
|
|
@ -786,6 +788,218 @@ class TwitchArchive:
|
||||||
self.send_notification('Chat Download Error',
|
self.send_notification('Chat Download Error',
|
||||||
f'Failed to download/render chat: {str(e)}')
|
f'Failed to download/render chat: {str(e)}')
|
||||||
return False
|
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:
|
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
|
'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
|
# Record the live stream
|
||||||
recording_completed = self._record_livestream(is_live, live_raw_path)
|
recording_completed = self._record_livestream(is_live, live_raw_path)
|
||||||
|
|
||||||
|
|
@ -936,13 +1161,56 @@ class TwitchArchive:
|
||||||
# Process the raw stream file
|
# Process the raw stream file
|
||||||
self._process_raw_stream(live_raw_path, live_proc_path)
|
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
|
vod_response = None
|
||||||
if self.shutdown_requested:
|
if self.shutdown_requested:
|
||||||
print(f'{Fore.YELLOW}Skipping VOD and chat download due to shutdown request{Style.RESET_ALL}')
|
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:
|
else:
|
||||||
# Try to match stream with VOD
|
# Try to match stream with VOD (with timeout)
|
||||||
vod_response = self._get_latest_vod()
|
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']:
|
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']
|
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}")
|
vod_path = os.path.join(self.video_path, f"{PREFIX_VOD}{filename_base}{vod_ext}")
|
||||||
self._download_vod(current_vod, vod_path)
|
self._download_vod(current_vod, vod_path)
|
||||||
|
|
||||||
# Download and render chat
|
# Download and render chat from VOD (if not already done via live chat)
|
||||||
chat_json_path = os.path.join(self.chatJSON_path, f"{PREFIX_CHAT}{filename_base}.json")
|
if not live_chat_downloaded:
|
||||||
chat_video_path = os.path.join(self.chatMP4_path, f"{PREFIX_CHAT}{filename_base}.mp4")
|
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)
|
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:
|
else:
|
||||||
print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}')
|
print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue