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",
|
||||
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue