From efb320eb05d7086efdad4a74fb80c9415e98a48d Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Mon, 9 Feb 2026 23:03:05 +0100 Subject: [PATCH] Refactor configuration values in global.json and global.schema.json to use boolean types for better clarity and consistency; update default values in twitch-archive.py accordingly. --- config/global.json.example | 26 +-- config/global.schema.json | 89 +++++----- twitch-archive.py | 327 +++++++++++++++++++++++++++---------- 3 files changed, 295 insertions(+), 147 deletions(-) diff --git a/config/global.json.example b/config/global.json.example index 13eb2ae..bf2b07b 100644 --- a/config/global.json.example +++ b/config/global.json.example @@ -4,25 +4,25 @@ "root_path": "archive", "rclone_path": "remote:path/to/streams", "refresh": 60.0, - "notifications": 0, - "downloadMETADATA": 1, - "downloadVOD": 1, - "downloadCHAT": 1, - "downloadLiveCHAT": 1, + "notifications": false, + "downloadMETADATA": true, + "downloadVOD": true, + "downloadCHAT": true, + "downloadLiveCHAT": true, "vodTimeout": 300, - "uploadCloud": 1, - "deleteFiles": 0, - "onlyRaw": 0, - "cleanRaw": 1, + "uploadCloud": true, + "deleteFiles": false, + "onlyRaw": false, + "cleanRaw": true, "hls_segments": 3, "hls_segmentsVOD": 10, - "streamlink_ttvlol": 0, + "streamlink_ttvlol": false, "ffmpeg_hwaccel": "auto", "ffmpeg_threads": 0, "ffmpeg_audio_codec": "aac", "ffmpeg_audio_samplerate": 48000, "ffmpeg_audio_bitrate": "192k", - "ffmpeg_error_recovery": 1, - "ffmpeg_faststart": 1, - "ffmpeg_progress": 0 + "ffmpeg_error_recovery": true, + "ffmpeg_faststart": true, + "ffmpeg_progress": false } diff --git a/config/global.schema.json b/config/global.schema.json index e29d4f5..f021775 100644 --- a/config/global.schema.json +++ b/config/global.schema.json @@ -28,34 +28,29 @@ "description": "Time between status checks in seconds (60.0 recommended for multiple streamers)" }, "notifications": { - "type": "integer", - "enum": [0, 1], - "default": 0, - "description": "Email notifications: 0 = disabled, 1 = enabled" + "type": "boolean", + "default": false, + "description": "Email notifications: false = disabled, true = enabled" }, "downloadMETADATA": { - "type": "integer", - "enum": [0, 1], - "default": 1, - "description": "Download stream metadata: 0 = disabled, 1 = enabled" + "type": "boolean", + "default": true, + "description": "Download stream metadata: false = disabled, true = enabled" }, "downloadVOD": { - "type": "integer", - "enum": [0, 1], - "default": 1, - "description": "Download VODs after stream ends: 0 = disabled, 1 = enabled" + "type": "boolean", + "default": true, + "description": "Download VODs after stream ends: false = disabled, true = enabled" }, "downloadCHAT": { - "type": "integer", - "enum": [0, 1], - "default": 1, - "description": "Download and render chat from VOD: 0 = disabled, 1 = enabled" + "type": "boolean", + "default": true, + "description": "Download and render chat from VOD: false = disabled, true = enabled" }, "downloadLiveCHAT": { - "type": "integer", - "enum": [0, 1], - "default": 1, - "description": "Download chat during live stream: 0 = disabled, 1 = enabled" + "type": "boolean", + "default": true, + "description": "Download chat during live stream: false = disabled, true = enabled" }, "vodTimeout": { "type": "integer", @@ -64,28 +59,24 @@ "description": "Seconds to wait for VOD to appear after stream ends" }, "uploadCloud": { - "type": "integer", - "enum": [0, 1], - "default": 1, - "description": "Upload to rclone remote: 0 = disabled, 1 = enabled" + "type": "boolean", + "default": true, + "description": "Upload to rclone remote: false = disabled, true = enabled" }, "deleteFiles": { - "type": "integer", - "enum": [0, 1], - "default": 0, - "description": "Delete local files after upload: 0 = disabled, 1 = enabled (BE CAREFUL)" + "type": "boolean", + "default": false, + "description": "Delete local files after upload: false = disabled, true = enabled (BE CAREFUL)" }, "onlyRaw": { - "type": "integer", - "enum": [0, 1], - "default": 0, - "description": "Keep only raw .ts files: 0 = convert to mp3/mp4, 1 = keep raw only" + "type": "boolean", + "default": false, + "description": "Keep only raw .ts files: false = convert to mp3/mp4, true = keep raw only" }, "cleanRaw": { - "type": "integer", - "enum": [0, 1], - "default": 1, - "description": "Delete raw .ts files after processing: 0 = keep, 1 = delete" + "type": "boolean", + "default": true, + "description": "Delete raw .ts files after processing: false = keep, true = delete" }, "hls_segments": { "type": "integer", @@ -102,9 +93,8 @@ "description": "Number of parallel download threads for VODs (1-10)" }, "streamlink_ttvlol": { - "type": "integer", - "enum": [0, 1], - "default": 0, + "type": "boolean", + "default": false, "description": "DEPRECATED: Ad-blocking with ttvlol (--twitch-proxy-playlist removed in newer streamlink)" }, "ffmpeg_hwaccel": { @@ -138,22 +128,19 @@ "description": "Audio bitrate (e.g., 128k, 192k, 256k, 320k)" }, "ffmpeg_error_recovery": { - "type": "integer", - "enum": [0, 1], - "default": 1, - "description": "Enable error recovery for corrupted/incomplete streams: 0 = disabled, 1 = enabled" + "type": "boolean", + "default": true, + "description": "Enable error recovery for corrupted/incomplete streams: false = disabled, true = enabled" }, "ffmpeg_faststart": { - "type": "integer", - "enum": [0, 1], - "default": 1, - "description": "Enable MP4 faststart flag for better streaming/playback: 0 = disabled, 1 = enabled" + "type": "boolean", + "default": true, + "description": "Enable MP4 faststart flag for better streaming/playback: false = disabled, true = enabled" }, "ffmpeg_progress": { - "type": "integer", - "enum": [0, 1], - "default": 0, - "description": "Show FFmpeg encoding progress: 0 = silent, 1 = verbose output" + "type": "boolean", + "default": false, + "description": "Show FFmpeg encoding progress: false = silent, true = verbose output" } } } diff --git a/twitch-archive.py b/twitch-archive.py index 2c38604..dfab86e 100644 --- a/twitch-archive.py +++ b/twitch-archive.py @@ -60,17 +60,17 @@ DEFAULT_CONFIG = { 'root_path': 'archive', 'rclone_path': 'remote:path/to/streams', 'refresh': 60.0, - 'streamlink_ttvlol': 0, - 'notifications': 0, - 'downloadMETADATA': 1, - 'downloadVOD': 1, - 'downloadCHAT': 1, - 'downloadLiveCHAT': 1, + 'streamlink_ttvlol': False, + 'notifications': False, + 'downloadMETADATA': True, + 'downloadVOD': True, + 'downloadCHAT': True, + 'downloadLiveCHAT': True, 'vodTimeout': 300, - 'uploadCloud': 1, - 'deleteFiles': 0, - 'onlyRaw': 0, - 'cleanRaw': 1, + 'uploadCloud': True, + 'deleteFiles': False, + 'onlyRaw': False, + 'cleanRaw': True, 'hls_segments': 3, 'hls_segmentsVOD': 10, # FFmpeg 8.0+ Enhancement Options @@ -79,9 +79,9 @@ DEFAULT_CONFIG = { 'ffmpeg_audio_codec': 'aac', # Audio codec for audio-only streams 'ffmpeg_audio_samplerate': 48000, # Audio sample rate (48000 recommended for broadcasts) 'ffmpeg_audio_bitrate': '192k', # Audio bitrate - 'ffmpeg_error_recovery': 1, # Enable error recovery for corrupted streams (0/1) - 'ffmpeg_faststart': 1, # Enable faststart for MP4 (better streaming compatibility) (0/1) - 'ffmpeg_progress': 0 # Show encoding progress (0/1) + 'ffmpeg_error_recovery': True, # Enable error recovery for corrupted streams + 'ffmpeg_faststart': True, # Enable faststart for MP4 (better streaming compatibility) + 'ffmpeg_progress': False # Show encoding progress } # ============================================================================ @@ -390,9 +390,9 @@ class TwitchArchive: self._print_toggle('Cloud upload', self.uploadCloud) # Warning messages - if self.deleteFiles == 1: + if self.deleteFiles: print(f'\n{Fore.RED}⚠ WARNING: Files will be DELETED after processing{Style.RESET_ALL}') - if self.uploadCloud == 0: + if not self.uploadCloud: print(f'{Fore.RED}⚠ CRITICAL: Files will be deleted WITHOUT cloud backup!{Style.RESET_ALL}') print(f'{Fore.YELLOW} Press CTRL+C to stop and change configuration{Style.RESET_ALL}') else: @@ -400,9 +400,9 @@ class TwitchArchive: print(f'\n{Fore.CYAN}{"=" * 60}{Style.RESET_ALL}\n') - def _print_toggle(self, label: str, value: int) -> None: + def _print_toggle(self, label: str, value: bool) -> None: """Helper method to print a configuration toggle in a consistent format.""" - status = f'{Fore.GREEN}Enabled{Style.RESET_ALL}' if value == 1 else f'{Fore.RED}Disabled{Style.RESET_ALL}' + status = f'{Fore.GREEN}Enabled{Style.RESET_ALL}' if value else f'{Fore.RED}Disabled{Style.RESET_ALL}' print(f'{label}: {status}') def run(self) -> None: @@ -528,7 +528,7 @@ class TwitchArchive: print(f'{Fore.YELLOW}⚠ Warning: Could not verify FFmpeg: {e}{Style.RESET_ALL}') # Check for TwitchDownloaderCLI (if VOD or Chat download enabled) - if self.downloadVOD == 1 or self.downloadCHAT == 1: + if self.downloadVOD or self.downloadCHAT: try: downloader_path = self._get_twitch_downloader_executable() if os.path.exists(downloader_path): @@ -634,7 +634,7 @@ class TwitchArchive: subject: Email subject line content: Email body content """ - if self.notifications != 1: + if not self.notifications: return try: @@ -768,7 +768,7 @@ class TwitchArchive: # Add ad-blocking if enabled (Note: twitch-proxy-playlist was removed in newer streamlink versions) # For ad-blocking, you may need to use alternative methods like --twitch-low-latency # or rely on Twitch's own ad-free viewing for subscribers - if self.streamlink_ttvlol == 1: + if self.streamlink_ttvlol: # The old --twitch-proxy-playlist option has been removed from streamlink # Consider using alternative ad-blocking approaches or updating your method print(f'{Fore.YELLOW}⚠ Warning: ttv-lol proxy option is deprecated in newer streamlink versions{Style.RESET_ALL}') @@ -792,7 +792,8 @@ class TwitchArchive: if self.shutdown_requested: print(f'{Fore.YELLOW}✓ Recording stopped by user{Style.RESET_ALL}') - return False + # Return True so processing continues - we still want to process what was recorded + return True print(f'{Fore.GREEN}✓ Stream recording complete{Style.RESET_ALL}') return True @@ -813,7 +814,7 @@ class TwitchArchive: print(f'{Fore.YELLOW}⚠ Raw file not found, skipping processing{Style.RESET_ALL}') return - if self.onlyRaw == 1: + if self.onlyRaw: print(f'{Fore.CYAN}Keeping raw .ts file (onlyRaw mode){Style.RESET_ALL}') return @@ -837,7 +838,7 @@ class TwitchArchive: cmd.extend(['-threads', str(self.ffmpeg_threads)]) # Add faststart for better streaming compatibility (MP4/M4A) - if self.ffmpeg_faststart == 1 and output_path.endswith(('.mp4', '.m4a')): + if self.ffmpeg_faststart and output_path.endswith(('.mp4', '.m4a')): cmd.extend(['-movflags', '+faststart']) cmd.append(output_path) @@ -865,7 +866,7 @@ class TwitchArchive: cmd.extend(['-threads', str(self.ffmpeg_threads)]) # Error recovery options for corrupted streams - if self.ffmpeg_error_recovery == 1: + if self.ffmpeg_error_recovery: cmd.extend([ '-fflags', '+genpts', # Generate missing timestamps '-avoid_negative_ts', 'make_zero', # Handle timestamp issues @@ -881,13 +882,13 @@ class TwitchArchive: ]) # Add faststart for MP4 files - if self.ffmpeg_faststart == 1 and output_path.endswith('.mp4'): + if self.ffmpeg_faststart and output_path.endswith('.mp4'): cmd.extend(['-movflags', '+faststart']) cmd.append(output_path) # Run ffmpeg with optional progress output - if self.ffmpeg_progress == 1: + if self.ffmpeg_progress: subprocess.call(cmd) else: subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) @@ -905,7 +906,7 @@ class TwitchArchive: Returns: bool: True if download succeeded, False otherwise """ - if self.downloadVOD != 1: + if not self.downloadVOD: return False print(f'\n{Fore.CYAN}Downloading VOD: {vod_info["title"]}{Style.RESET_ALL}') @@ -959,7 +960,7 @@ class TwitchArchive: Returns: bool: True if succeeded, False otherwise """ - if self.downloadCHAT != 1: + if not self.downloadCHAT: return False print(f'\n{Fore.CYAN}Downloading chat: {vod_info["title"]}{Style.RESET_ALL}') @@ -1041,7 +1042,7 @@ class TwitchArchive: Returns: subprocess.Popen: The process handle, or None if failed to start """ - if self.downloadLiveCHAT != 1: + if not self.downloadLiveCHAT: return None print(f'\n{Fore.CYAN}Starting live chat download...{Style.RESET_ALL}') @@ -1086,7 +1087,7 @@ class TwitchArchive: Returns: bool: True if succeeded, False otherwise """ - if self.downloadCHAT != 1: + if not self.downloadCHAT: return False print(f'\n{Fore.CYAN}Downloading chat: {vod_info["title"]}{Style.RESET_ALL}') @@ -1250,7 +1251,7 @@ class TwitchArchive: vod_info: VOD metadata from Twitch API filename_base: Base filename (without extension) """ - if self.downloadMETADATA != 1: + if not self.downloadMETADATA: return metadata_path = os.path.join(self.metadata_path, f"{PREFIX_METADATA}{filename_base}.json") @@ -1385,11 +1386,11 @@ class TwitchArchive: 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'): + if self.downloadLiveCHAT 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: + elif self.downloadLiveCHAT: print(f'{Fore.YELLOW}⚠ No VOD ID available yet for live chat download{Style.RESET_ALL}') # Record the live stream @@ -1482,7 +1483,7 @@ class TwitchArchive: print(f'{Fore.YELLOW}⚠ No matching VOD found for this stream{Style.RESET_ALL}') # Clean up raw files if configured - if self.cleanRaw == 1 and os.path.exists(live_raw_path): + if self.cleanRaw and os.path.exists(live_raw_path): print(f'{Fore.YELLOW}Deleting raw .ts file...{Style.RESET_ALL}') os.remove(live_raw_path) @@ -1490,7 +1491,7 @@ class TwitchArchive: upload_success = self._upload_to_cloud(filename_base) # Delete local files if configured and upload succeeded - if self.deleteFiles == 1 and upload_success: + if self.deleteFiles and upload_success: self._delete_local_files(filename_base, live_raw_path, live_proc_path) # Done processing this stream @@ -1542,7 +1543,7 @@ class TwitchArchive: Returns: bool: True if upload succeeded or is disabled, False if failed """ - if self.uploadCloud != 1: + if not self.uploadCloud: return True # Consider upload "successful" if disabled print(f'\n{Fore.CYAN}Uploading to cloud storage...{Style.RESET_ALL}') @@ -1618,18 +1619,18 @@ class TwitchArchive: files_to_delete = [] # Live files - if self.cleanRaw == 0 and os.path.exists(live_raw_path): + if not self.cleanRaw and os.path.exists(live_raw_path): files_to_delete.append(live_raw_path) if os.path.exists(live_proc_path): files_to_delete.append(live_proc_path) # VOD files - if self.downloadVOD == 1: + if self.downloadVOD: vod_raw = os.path.join(self.raw_path, f"{PREFIX_VOD}{filename_base}.ts") vod_mp4 = os.path.join(self.video_path, f"{PREFIX_VOD}{filename_base}.mp4") vod_mp3 = os.path.join(self.video_path, f"{PREFIX_VOD}{filename_base}.mp3") - if self.cleanRaw == 0 and os.path.exists(vod_raw): + if not self.cleanRaw and os.path.exists(vod_raw): files_to_delete.append(vod_raw) if os.path.exists(vod_mp4): files_to_delete.append(vod_mp4) @@ -1637,7 +1638,7 @@ class TwitchArchive: files_to_delete.append(vod_mp3) # Chat files - if self.downloadCHAT == 1: + if self.downloadCHAT: chat_json = os.path.join(self.chatJSON_path, f"{PREFIX_CHAT}{filename_base}.json") chat_mp4 = os.path.join(self.chatMP4_path, f"{PREFIX_CHAT}{filename_base}.mp4") @@ -1647,7 +1648,7 @@ class TwitchArchive: files_to_delete.append(chat_mp4) # Metadata files - if self.downloadMETADATA == 1: + if self.downloadMETADATA: metadata = os.path.join(self.metadata_path, f"{PREFIX_METADATA}{filename_base}.json") if os.path.exists(metadata): files_to_delete.append(metadata) @@ -1672,17 +1673,20 @@ class TwitchArchiveManager: Manages multiple TwitchArchive instances for monitoring multiple streamers. """ - def __init__(self, specific_streamer: Optional[str] = None): + def __init__(self, specific_streamer: Optional[str] = None, verbose: bool = False): """ Initialize the manager. Args: specific_streamer: If provided, only monitor this streamer (ignore enabled status) + verbose: Enable verbose debug output """ self.config_manager = ConfigManager() self.specific_streamer = specific_streamer + self.verbose = verbose self.archivers: Dict[str, TwitchArchive] = {} self.shutdown_requested = False + self.active_recordings: Dict[str, str] = {} # Track active recordings: {username: stream_id} # Setup signal handlers signal.signal(signal.SIGTERM, self._signal_handler) @@ -1705,7 +1709,7 @@ class TwitchArchiveManager: list: List of streamer usernames to monitor """ if self.specific_streamer: - # Monitor only the specified streamer + # Monitor only the specified streamer (ignore enabled flag) return [self.specific_streamer] else: # Monitor all enabled streamers @@ -1789,10 +1793,17 @@ class TwitchArchiveManager: Checks each streamer's status and processes streams as needed. """ last_check = {} + last_status_print = time.time() while not self.shutdown_requested: current_time = time.time() + # Print periodic status every 60 seconds + if current_time - last_status_print >= 60: + status_line = " | ".join([f"{username}: checking" for username in self.archivers.keys()]) + print(f'{Fore.CYAN}[Status] {status_line}{Style.RESET_ALL}') + last_status_print = current_time + for username, archiver in self.archivers.items(): # Check if enough time has passed since last check for this streamer if username not in last_check or (current_time - last_check[username]) >= archiver.refresh: @@ -1800,27 +1811,68 @@ class TwitchArchiveManager: # Check stream status try: - stream_info = archiver._check_stream_status() + response = archiver._check_stream_status() - if stream_info: - # Stream is live - stream_id = stream_info['archiveVideo']['id'] - - if not archiver._is_stream_already_processed(stream_id): - print(f'\n{Fore.GREEN}[{username}] Stream detected!{Style.RESET_ALL}') - print(f'{Fore.CYAN}Title: {stream_info["title"]}{Style.RESET_ALL}') + # Debug: Print the full response (if verbose) + if self.verbose: + print(f'\n{Fore.MAGENTA}[DEBUG {username}] API Response: {response}{Style.RESET_ALL}') + + stream_data = response['data']['user']['stream'] if response else None + + if self.verbose: + print(f'{Fore.MAGENTA}[DEBUG {username}] Stream data: {stream_data}{Style.RESET_ALL}') + + if stream_data: + # Stream is live - check if it has required data + if stream_data.get('archiveVideo') and stream_data['archiveVideo'].get('id'): + # Create composite stream ID like single-streamer mode + # This prevents duplicate recordings in the same session + stream_id = f"{stream_data['createdAt']} - {username} - {stream_data.get('title', 'Untitled')}" - # Process the stream - self._process_stream(archiver, stream_info, stream_id) + if self.verbose: + print(f'{Fore.MAGENTA}[DEBUG {username}] VOD ID: {stream_data["archiveVideo"]["id"]}{Style.RESET_ALL}') + print(f'{Fore.MAGENTA}[DEBUG {username}] Composite Stream ID: {stream_id}{Style.RESET_ALL}') - # Mark as processed - archiver._mark_stream_as_processed(stream_id) + # Check if we're currently recording this stream + currently_recording = username in self.active_recordings and self.active_recordings[username] == stream_id + + if self.verbose: + print(f'{Fore.MAGENTA}[DEBUG {username}] Currently recording: {currently_recording}{Style.RESET_ALL}') + print(f'{Fore.MAGENTA}[DEBUG {username}] Active recordings: {self.active_recordings}{Style.RESET_ALL}') + + # Record if not currently recording (ignore .log file - always record if live) + if not currently_recording: + print(f'\n{Fore.GREEN}[{username}] Stream detected!{Style.RESET_ALL}') + print(f'{Fore.CYAN}Title: {stream_data.get("title", "No title")}{Style.RESET_ALL}') + print(f'{Fore.CYAN}Started at: {stream_data["createdAt"]}{Style.RESET_ALL}') + + # Mark as currently recording + self.active_recordings[username] = stream_id + + # Process the stream (this blocks until stream ends) + self._process_stream(archiver, stream_data, stream_id) + + # Mark as processed in log (for record keeping) + archiver._mark_stream_as_processed(stream_id) + + # Remove from active recordings + if username in self.active_recordings: + del self.active_recordings[username] + else: + if self.verbose: + print(f'{Fore.CYAN}[{username}] Currently recording this stream, skipping duplicate...{Style.RESET_ALL}') + else: + # Stream is live but VOD ID not available yet + print(f'{Fore.YELLOW}[{username}] Stream is live but VOD ID not ready yet (title: {stream_data.get("title", "No title")}){Style.RESET_ALL}') else: - # Not live - check for new VODs if needed - pass + # Not live + if self.verbose: + print(f'{Fore.CYAN}[{username}] Offline - checking again in {archiver.refresh}s{Style.RESET_ALL}', end='\r') except Exception as e: print(f'{Fore.RED}[{username}] Error checking stream: {e}{Style.RESET_ALL}') + import traceback + traceback.print_exc() # Sleep briefly before next iteration time.sleep(1) @@ -1845,12 +1897,18 @@ class TwitchArchiveManager: timestamp = datetime.now(timezone('UTC')).strftime("%Y%m%d_%Hh%Mm%Ss") filename_base = f"{PREFIX_LIVE}{archiver.username}_{timestamp}" + # Parse stream start time + live_date = datetime.strptime( + stream_info["createdAt"], '%Y-%m-%dT%H:%M:%SZ' + ).replace(tzinfo=timezone('UTC')).astimezone(tz=None).replace(tzinfo=None) + # Define paths raw_extension = '.ts' proc_extension = '.mp3' if archiver.quality == 'audio_only' else '.mp4' live_raw_path = str(archiver.raw_path / f"{filename_base}{raw_extension}") live_proc_path = str(archiver.video_path / f"{filename_base}{proc_extension}") + chat_json_path = str(archiver.chatJSON_path / f"{PREFIX_CHAT}{filename_base}.json") # Send notification archiver.send_notification( @@ -1858,35 +1916,133 @@ class TwitchArchiveManager: f"Recording: {stream_info['title']}" ) + # Start live chat download if enabled and VOD ID is available + live_chat_process = None + if archiver.downloadLiveCHAT and stream_info.get('archiveVideo') and stream_info['archiveVideo'].get('id'): + live_vod_id = stream_info['archiveVideo']['id'] + print(f'{Fore.CYAN}Live VOD ID detected: {live_vod_id}{Style.RESET_ALL}') + live_chat_process = archiver._download_live_chat(live_vod_id, chat_json_path) + elif archiver.downloadLiveCHAT: + print(f'{Fore.YELLOW}⚠ No VOD ID available yet for live chat download{Style.RESET_ALL}') + # Record livestream recording_successful = archiver._record_livestream(stream_info, live_raw_path) - if not recording_successful: + # Check if raw file exists (may exist even after interrupted recording) + if not os.path.exists(live_raw_path): + print(f'{Fore.RED}✗ No recording file found, skipping processing{Style.RESET_ALL}') return - # Process raw stream - if archiver.onlyRaw != 1: - archiver._process_raw_stream(live_raw_path, live_proc_path) - - # Clean up raw file if configured - if archiver.cleanRaw == 1 and os.path.exists(live_raw_path): - os.remove(live_raw_path) + # Get file size to check if anything was recorded + file_size = os.path.getsize(live_raw_path) + if file_size < 1024: # Less than 1KB means essentially nothing was recorded + print(f'{Fore.RED}✗ Recording file too small ({file_size} bytes), skipping processing{Style.RESET_ALL}') + return - # Save metadata - if archiver.downloadMETADATA == 1: - archiver._save_metadata(stream_info, filename_base) + print(f'{Fore.CYAN}Processing recorded content ({file_size / (1024*1024):.2f} MB)...{Style.RESET_ALL}') + + # Process raw stream + if not archiver.onlyRaw: + archiver._process_raw_stream(live_raw_path, live_proc_path) + + # Wait for live chat download if it was started + live_chat_downloaded = False + if live_chat_process is not None: + live_chat_downloaded = archiver._wait_for_chat_download(live_chat_process, chat_json_path) + + # Render live chat if downloaded successfully + if live_chat_downloaded: + chat_video_path = str(archiver.chatMP4_path / f"{PREFIX_CHAT}{filename_base}.mp4") + archiver._render_chat(chat_json_path, chat_video_path) # Wait for VOD and download it - if archiver.downloadVOD == 1 and archiver.vodTimeout > 0: - # This would need the full VOD logic from loopcheck - pass + vod_response = None + if archiver.vodTimeout == 0: + print(f'{Fore.CYAN}VOD check disabled (vodTimeout=0). Skipping VOD download.{Style.RESET_ALL}') + elif archiver.shutdown_requested: + print(f'{Fore.YELLOW}Skipping VOD download due to shutdown request{Style.RESET_ALL}') + else: + # Try to match stream with VOD (with timeout) + print(f'{Fore.CYAN}Waiting for VOD to become available (timeout: {archiver.vodTimeout}s)...{Style.RESET_ALL}') + vod_found = False + vod_wait_start = time.time() + + while time.time() - vod_wait_start < archiver.vodTimeout: + # Check for shutdown request + if archiver.shutdown_requested: + print(f'\n{Fore.YELLOW}VOD check interrupted by shutdown{Style.RESET_ALL}') + break + + vod_response = archiver._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') + time.sleep(min(10, archiver.vodTimeout - (time.time() - vod_wait_start))) + + if not vod_found: + print(f'\n{Fore.YELLOW}⚠ VOD not found after {archiver.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 + + # Process VOD if found + 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): + print(f'\n{Fore.GREEN}✓ Found matching VOD{Style.RESET_ALL}') + + # Save metadata + if archiver.downloadMETADATA: + archiver._save_metadata(current_vod, filename_base) + + # Download VOD + if archiver.downloadVOD: + vod_ext = '.mp3' if archiver.quality == 'audio_only' else '.mp4' + vod_path = str(archiver.video_path / f"{PREFIX_VOD}{filename_base}{vod_ext}") + archiver._download_vod(current_vod, vod_path) + + # Download and render chat from VOD (if not already done via live chat) + if archiver.downloadCHAT and not live_chat_downloaded: + chat_video_path = str(archiver.chatMP4_path / f"{PREFIX_CHAT}{filename_base}.mp4") + archiver._download_and_render_chat(current_vod, chat_json_path, chat_video_path) + elif live_chat_downloaded: + 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}') + elif archiver.downloadMETADATA: + # Save what metadata we have from the live stream + archiver._save_metadata(stream_info, filename_base) + + # Clean up raw file if configured + if archiver.cleanRaw and os.path.exists(live_raw_path): + print(f'{Fore.YELLOW}Deleting raw .ts file...{Style.RESET_ALL}') + os.remove(live_raw_path) # Upload to cloud if configured - if archiver.uploadCloud == 1: - archiver._upload_to_cloud(filename_base) + upload_success = False + if archiver.uploadCloud: + upload_success = archiver._upload_to_cloud(filename_base) # Delete files if configured - if archiver.deleteFiles == 1: + if archiver.deleteFiles and upload_success: archiver._delete_local_files(filename_base, live_raw_path, live_proc_path) # Send completion notification @@ -1933,6 +2089,7 @@ TWITCH ARCHIVE - Automated Stream Recording & Archiving {Fore.GREEN}OPTIONS:{Style.RESET_ALL} -h, --help Display this help information -u, --username Monitor only this Twitch channel + --verbose Enable verbose debug output --legacy Force legacy mode (use config.json) {Fore.GREEN}LEGACY OPTIONS (when using --legacy):{Style.RESET_ALL} @@ -1955,6 +2112,7 @@ TWITCH ARCHIVE - Automated Stream Recording & Archiving {Fore.CYAN}EXAMPLES:{Style.RESET_ALL} python twitch-archive.py # Monitor all enabled streamers python twitch-archive.py -u vinesauce # Monitor only vinesauce + python twitch-archive.py -u hackerling --verbose # Monitor with debug output python twitch-archive.py --legacy # Use old config.json mode {Fore.CYAN}{"=" * 70}{Style.RESET_ALL} @@ -1965,7 +2123,7 @@ TWITCH ARCHIVE - Automated Stream Recording & Archiving argv, "h:u:q:a:v:c:m:r:d:n:", ["help", "username=", "quality=", "ttv-lol=", "vod=", "chat=", - "metadata=", "upload=", "delete=", "notifications=", "legacy"] + "metadata=", "upload=", "delete=", "notifications=", "legacy", "verbose"] ) except getopt.GetoptError as e: print(f'{Fore.RED}Error: {e}{Style.RESET_ALL}\n') @@ -1977,31 +2135,34 @@ TWITCH ARCHIVE - Automated Stream Recording & Archiving # Parse command line args legacy_overrides = {} + verbose_mode = False for opt, arg in opts: if opt in ('-h', '--help'): print(help_msg) sys.exit(0) elif opt in ("-u", "--username"): specific_streamer = arg + elif opt == "--verbose": + verbose_mode = True elif opt == "--legacy": use_legacy_mode = True # Legacy options (only used in legacy mode) elif opt in ("-q", "--quality"): legacy_overrides['quality'] = arg elif opt in ("-a", "--ttv-lol"): - legacy_overrides['streamlink_ttvlol'] = int(arg) + legacy_overrides['streamlink_ttvlol'] = bool(int(arg)) elif opt in ("-v", "--vod"): - legacy_overrides['downloadVOD'] = int(arg) + legacy_overrides['downloadVOD'] = bool(int(arg)) elif opt in ("-c", "--chat"): - legacy_overrides['downloadCHAT'] = int(arg) + legacy_overrides['downloadCHAT'] = bool(int(arg)) elif opt in ("-m", "--metadata"): - legacy_overrides['downloadMETADATA'] = int(arg) + legacy_overrides['downloadMETADATA'] = bool(int(arg)) elif opt in ("-r", "--upload"): - legacy_overrides['uploadCloud'] = int(arg) + legacy_overrides['uploadCloud'] = bool(int(arg)) elif opt in ("-d", "--delete"): - legacy_overrides['deleteFiles'] = int(arg) + legacy_overrides['deleteFiles'] = bool(int(arg)) elif opt in ("-n", "--notifications"): - legacy_overrides['notifications'] = int(arg) + legacy_overrides['notifications'] = bool(int(arg)) # Determine which mode to use if use_legacy_mode or (legacy_config_exists and not specific_streamer and not os.path.exists('config/global.json')): @@ -2019,7 +2180,7 @@ TWITCH ARCHIVE - Automated Stream Recording & Archiving twitch_archive.run() else: # New multi-streamer mode - manager = TwitchArchiveManager(specific_streamer=specific_streamer) + manager = TwitchArchiveManager(specific_streamer=specific_streamer, verbose=verbose_mode) manager.run()