Implement configuration management for multi-streamer support and update .gitignore
This commit is contained in:
parent
dd8abf03d3
commit
7f8b3d1bf9
6 changed files with 655 additions and 77 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,5 +1,13 @@
|
|||
# User configuration file (contains personal settings)
|
||||
# User configuration files (contains personal settings)
|
||||
config.json
|
||||
config/global.json
|
||||
|
||||
# Streamer-specific configurations (personal settings)
|
||||
config/streamers/*.json
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Environments
|
||||
.env
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
{
|
||||
"_comment": "Copy this file to config.json and edit with your settings",
|
||||
|
||||
"username": "your_twitch_username",
|
||||
"_username_comment": "Twitch streamer username to monitor and archive",
|
||||
|
||||
"quality": "best",
|
||||
"_quality_comment": "Quality options: best/source, high/720p, medium/540p, low/360p, audio_only",
|
||||
|
||||
"root_path": "archive",
|
||||
"_root_path_comment": "Path where this script saves everything (livestream, VODs, chat, metadata)",
|
||||
|
||||
"rclone_path": "remote:path/to/streams",
|
||||
"_rclone_path_comment": "Path to rclone remote storage (e.g., MyDrive:Backups/streams)",
|
||||
|
||||
"refresh": 60.0,
|
||||
"_refresh_comment": "Time between checking in seconds (5.0 is recommended), avoid less than 1.0",
|
||||
|
||||
"streamlink_ttvlol": 0,
|
||||
"_streamlink_ttvlol_comment": "0 = disable, 1 = enable blocking ads with ttvlol (DEPRECATED: --twitch-proxy-playlist removed in newer streamlink versions)",
|
||||
|
||||
"notifications": 0,
|
||||
"_notifications_comment": "0 = disable, 1 = enable email notifications",
|
||||
|
||||
"downloadMETADATA": 1,
|
||||
"_downloadMETADATA_comment": "0 = disable, 1 = enable metadata downloading",
|
||||
|
||||
"downloadVOD": 1,
|
||||
"_downloadVOD_comment": "0 = disable, 1 = enable VOD downloading after stream finished",
|
||||
|
||||
"downloadCHAT": 1,
|
||||
"_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",
|
||||
|
||||
"deleteFiles": 0,
|
||||
"_deleteFiles_comment": "0 = disable, 1 = enable deleting files after upload (BE CAREFUL WITH THIS OPTION)",
|
||||
|
||||
"onlyRaw": 0,
|
||||
"_onlyRaw_comment": "0 = convert ts files to mp3/mp4, 1 = keep only raw ts files for recording",
|
||||
|
||||
"cleanRaw": 1,
|
||||
"_cleanRaw_comment": "0 = keep raw .ts files, 1 = delete raw .ts files after processing",
|
||||
|
||||
"hls_segments": 3,
|
||||
"_hls_segments_comment": "Number of threads for live stream downloading (1-10, recommended 2-3)",
|
||||
|
||||
"hls_segmentsVOD": 10,
|
||||
"_hls_segmentsVOD_comment": "Number of threads for VOD downloading (1-10)"
|
||||
}
|
||||
28
config/global.json.example
Normal file
28
config/global.json.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"$schema": "./global.schema.json",
|
||||
"quality": "best",
|
||||
"root_path": "archive",
|
||||
"rclone_path": "remote:path/to/streams",
|
||||
"refresh": 60.0,
|
||||
"notifications": 0,
|
||||
"downloadMETADATA": 1,
|
||||
"downloadVOD": 1,
|
||||
"downloadCHAT": 1,
|
||||
"downloadLiveCHAT": 1,
|
||||
"vodTimeout": 300,
|
||||
"uploadCloud": 1,
|
||||
"deleteFiles": 0,
|
||||
"onlyRaw": 0,
|
||||
"cleanRaw": 1,
|
||||
"hls_segments": 3,
|
||||
"hls_segmentsVOD": 10,
|
||||
"streamlink_ttvlol": 0,
|
||||
"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
|
||||
}
|
||||
159
config/global.schema.json
Normal file
159
config/global.schema.json
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://github.com/piero0920/Twitch-Archive/config/global.schema.json",
|
||||
"title": "Twitch Archive Global Configuration",
|
||||
"description": "Global settings and defaults for all streamers",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"quality": {
|
||||
"type": "string",
|
||||
"enum": ["best", "source", "high", "720p", "medium", "480p", "low", "360p", "audio_only"],
|
||||
"default": "best",
|
||||
"description": "Default video quality for stream recording"
|
||||
},
|
||||
"root_path": {
|
||||
"type": "string",
|
||||
"default": "archive",
|
||||
"description": "Root directory path where all archives are saved"
|
||||
},
|
||||
"rclone_path": {
|
||||
"type": "string",
|
||||
"default": "remote:path/to/streams",
|
||||
"description": "Default rclone remote path for cloud uploads (can be overridden per streamer)"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "number",
|
||||
"default": 60.0,
|
||||
"minimum": 1.0,
|
||||
"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"
|
||||
},
|
||||
"downloadMETADATA": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 1,
|
||||
"description": "Download stream metadata: 0 = disabled, 1 = enabled"
|
||||
},
|
||||
"downloadVOD": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 1,
|
||||
"description": "Download VODs after stream ends: 0 = disabled, 1 = enabled"
|
||||
},
|
||||
"downloadCHAT": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 1,
|
||||
"description": "Download and render chat from VOD: 0 = disabled, 1 = enabled"
|
||||
},
|
||||
"downloadLiveCHAT": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 1,
|
||||
"description": "Download chat during live stream: 0 = disabled, 1 = enabled"
|
||||
},
|
||||
"vodTimeout": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 0,
|
||||
"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"
|
||||
},
|
||||
"deleteFiles": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 0,
|
||||
"description": "Delete local files after upload: 0 = disabled, 1 = 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"
|
||||
},
|
||||
"cleanRaw": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 1,
|
||||
"description": "Delete raw .ts files after processing: 0 = keep, 1 = delete"
|
||||
},
|
||||
"hls_segments": {
|
||||
"type": "integer",
|
||||
"default": 3,
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"description": "Number of parallel download threads for live streams (1-10, recommended 2-3)"
|
||||
},
|
||||
"hls_segmentsVOD": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"description": "Number of parallel download threads for VODs (1-10)"
|
||||
},
|
||||
"streamlink_ttvlol": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 0,
|
||||
"description": "DEPRECATED: Ad-blocking with ttvlol (--twitch-proxy-playlist removed in newer streamlink)"
|
||||
},
|
||||
"ffmpeg_hwaccel": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "nvenc", "qsv", "amf", "vaapi", "none"],
|
||||
"default": "auto",
|
||||
"description": "Hardware acceleration: auto (recommended), nvenc (NVIDIA), qsv (Intel), amf (AMD), vaapi (Linux), none (software only)"
|
||||
},
|
||||
"ffmpeg_threads": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"minimum": 0,
|
||||
"description": "FFmpeg encoding thread count (0 = auto-detect optimal count)"
|
||||
},
|
||||
"ffmpeg_audio_codec": {
|
||||
"type": "string",
|
||||
"enum": ["aac", "libmp3lame"],
|
||||
"default": "aac",
|
||||
"description": "Audio codec for audio_only quality streams (aac recommended)"
|
||||
},
|
||||
"ffmpeg_audio_samplerate": {
|
||||
"type": "integer",
|
||||
"enum": [44100, 48000],
|
||||
"default": 48000,
|
||||
"description": "Audio sample rate in Hz (48000 recommended for broadcasts, 44100 for CD quality)"
|
||||
},
|
||||
"ffmpeg_audio_bitrate": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+k$",
|
||||
"default": "192k",
|
||||
"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"
|
||||
},
|
||||
"ffmpeg_faststart": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 1,
|
||||
"description": "Enable MP4 faststart flag for better streaming/playback: 0 = disabled, 1 = enabled"
|
||||
},
|
||||
"ffmpeg_progress": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1],
|
||||
"default": 0,
|
||||
"description": "Show FFmpeg encoding progress: 0 = silent, 1 = verbose output"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
config/streamers/streamer.json.example
Normal file
5
config/streamers/streamer.json.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"$schema": "./streamer.schema.json",
|
||||
"username": "streamer_username",
|
||||
"enabled": false
|
||||
}
|
||||
|
|
@ -88,6 +88,152 @@ DEFAULT_CONFIG = {
|
|||
# MAIN CLASS
|
||||
# ============================================================================
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Manages global and per-streamer configurations.
|
||||
|
||||
Loads global defaults from config/global.json and merges with per-streamer
|
||||
configs from config/streamers/*.json.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the configuration manager."""
|
||||
self.config_dir = pathlib.Path(__file__).parent / "config"
|
||||
self.streamers_dir = self.config_dir / "streamers"
|
||||
self.global_config = self._load_global_config()
|
||||
|
||||
def _load_global_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load global configuration from config/global.json.
|
||||
|
||||
Returns:
|
||||
dict: Global configuration with defaults
|
||||
"""
|
||||
global_file = self.config_dir / "global.json"
|
||||
|
||||
# Start with DEFAULT_CONFIG as ultimate fallback
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
|
||||
# Try to load global config
|
||||
if global_file.exists():
|
||||
try:
|
||||
with open(global_file, 'r', encoding='utf-8') as f:
|
||||
user_config = json.load(f)
|
||||
# Filter out comment fields and schema references
|
||||
user_config = {k: v for k, v in user_config.items()
|
||||
if not k.startswith('_') and k != '$schema'}
|
||||
config.update(user_config)
|
||||
print(f'{Fore.GREEN}✓ Global configuration loaded from config/global.json{Style.RESET_ALL}')
|
||||
except json.JSONDecodeError as e:
|
||||
print(f'{Fore.YELLOW}⚠ Warning: Invalid JSON in config/global.json: {e}{Style.RESET_ALL}')
|
||||
print(f'{Fore.YELLOW} Using default configuration{Style.RESET_ALL}')
|
||||
except Exception as e:
|
||||
print(f'{Fore.YELLOW}⚠ Warning: Could not load config/global.json: {e}{Style.RESET_ALL}')
|
||||
else:
|
||||
print(f'{Fore.YELLOW}⚠ Warning: config/global.json not found{Style.RESET_ALL}')
|
||||
print(f'{Fore.CYAN} → Create config/global.json with default settings{Style.RESET_ALL}')
|
||||
|
||||
return config
|
||||
|
||||
def load_streamer_config(self, username: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load configuration for a specific streamer.
|
||||
|
||||
Merges global config with streamer-specific overrides.
|
||||
|
||||
Args:
|
||||
username: Twitch username
|
||||
|
||||
Returns:
|
||||
dict: Complete configuration for the streamer
|
||||
"""
|
||||
# Start with global config
|
||||
config = self.global_config.copy()
|
||||
|
||||
# Load streamer-specific config
|
||||
streamer_file = self.streamers_dir / f"{username}.json"
|
||||
|
||||
if streamer_file.exists():
|
||||
try:
|
||||
with open(streamer_file, 'r', encoding='utf-8') as f:
|
||||
streamer_config = json.load(f)
|
||||
# Filter out comments and schema references
|
||||
streamer_config = {k: v for k, v in streamer_config.items()
|
||||
if not k.startswith('_') and k != '$schema'}
|
||||
# Merge streamer config (overrides global)
|
||||
config.update(streamer_config)
|
||||
print(f'{Fore.GREEN}✓ Loaded config for {username}{Style.RESET_ALL}')
|
||||
except json.JSONDecodeError as e:
|
||||
print(f'{Fore.YELLOW}⚠ Warning: Invalid JSON in {streamer_file}: {e}{Style.RESET_ALL}')
|
||||
except Exception as e:
|
||||
print(f'{Fore.YELLOW}⚠ Warning: Could not load {streamer_file}: {e}{Style.RESET_ALL}')
|
||||
else:
|
||||
# Create default config for new streamer
|
||||
print(f'{Fore.CYAN}→ Creating default config for new streamer: {username}{Style.RESET_ALL}')
|
||||
self.create_default_streamer_config(username)
|
||||
config['username'] = username
|
||||
config['enabled'] = True
|
||||
|
||||
# Ensure username is set
|
||||
config['username'] = username
|
||||
|
||||
return config
|
||||
|
||||
def create_default_streamer_config(self, username: str) -> None:
|
||||
"""
|
||||
Create a default configuration file for a new streamer.
|
||||
|
||||
Args:
|
||||
username: Twitch username
|
||||
"""
|
||||
# Ensure streamers directory exists
|
||||
self.streamers_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
streamer_file = self.streamers_dir / f"{username}.json"
|
||||
|
||||
default_config = {
|
||||
"$schema": "./streamer.schema.json",
|
||||
"username": username,
|
||||
"enabled": True
|
||||
}
|
||||
|
||||
try:
|
||||
with open(streamer_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
print(f'{Fore.GREEN}✓ Created config file: config/streamers/{username}.json{Style.RESET_ALL}')
|
||||
print(f'{Fore.CYAN} → Edit the file to add custom settings or overrides{Style.RESET_ALL}')
|
||||
except Exception as e:
|
||||
print(f'{Fore.RED}✗ Could not create config file for {username}: {e}{Style.RESET_ALL}')
|
||||
|
||||
def get_all_enabled_streamers(self) -> list:
|
||||
"""
|
||||
Get list of all enabled streamers.
|
||||
|
||||
Returns:
|
||||
list: List of usernames configured and enabled
|
||||
"""
|
||||
if not self.streamers_dir.exists():
|
||||
return []
|
||||
|
||||
enabled_streamers = []
|
||||
|
||||
for config_file in self.streamers_dir.glob("*.json"):
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
# Filter comments and schema references
|
||||
config = {k: v for k, v in config.items()
|
||||
if not k.startswith('_') and k != '$schema'}
|
||||
|
||||
if config.get('enabled', False):
|
||||
username = config.get('username') or config_file.stem
|
||||
enabled_streamers.append(username)
|
||||
except Exception as e:
|
||||
print(f'{Fore.YELLOW}⚠ Warning: Could not read {config_file}: {e}{Style.RESET_ALL}')
|
||||
|
||||
return enabled_streamers
|
||||
|
||||
|
||||
class TwitchArchive:
|
||||
"""
|
||||
Main class for the Twitch Archive system.
|
||||
|
|
@ -96,9 +242,21 @@ class TwitchArchive:
|
|||
VODs, chat logs, and metadata. Can optionally upload to cloud storage.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the TwitchArchive with configuration settings."""
|
||||
self.load_config()
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Initialize the TwitchArchive with configuration settings.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary. If None, loads from legacy config.json
|
||||
"""
|
||||
if config is None:
|
||||
# Legacy mode: load from config.json
|
||||
self.load_config()
|
||||
else:
|
||||
# New mode: use provided config
|
||||
for key, value in config.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
self.os = self._detect_operating_system()
|
||||
self.paths_initialized = False
|
||||
self.shutdown_requested = False
|
||||
|
|
@ -1505,6 +1663,239 @@ class TwitchArchive:
|
|||
print(f'{Fore.RED}\n✓ Cleanup complete{Style.RESET_ALL}')
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MULTI-STREAMER MANAGER
|
||||
# ============================================================================
|
||||
|
||||
class TwitchArchiveManager:
|
||||
"""
|
||||
Manages multiple TwitchArchive instances for monitoring multiple streamers.
|
||||
"""
|
||||
|
||||
def __init__(self, specific_streamer: Optional[str] = None):
|
||||
"""
|
||||
Initialize the manager.
|
||||
|
||||
Args:
|
||||
specific_streamer: If provided, only monitor this streamer (ignore enabled status)
|
||||
"""
|
||||
self.config_manager = ConfigManager()
|
||||
self.specific_streamer = specific_streamer
|
||||
self.archivers: Dict[str, TwitchArchive] = {}
|
||||
self.shutdown_requested = False
|
||||
|
||||
# Setup signal handlers
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle shutdown signals gracefully."""
|
||||
print(f'\n{Fore.YELLOW}⚠ Shutdown signal received...{Style.RESET_ALL}')
|
||||
self.shutdown_requested = True
|
||||
|
||||
# Signal all archivers to shut down
|
||||
for archiver in self.archivers.values():
|
||||
archiver.shutdown_requested = True
|
||||
|
||||
def _get_streamers_to_monitor(self) -> list:
|
||||
"""
|
||||
Get list of streamers to monitor.
|
||||
|
||||
Returns:
|
||||
list: List of streamer usernames to monitor
|
||||
"""
|
||||
if self.specific_streamer:
|
||||
# Monitor only the specified streamer
|
||||
return [self.specific_streamer]
|
||||
else:
|
||||
# Monitor all enabled streamers
|
||||
return self.config_manager.get_all_enabled_streamers()
|
||||
|
||||
def _initialize_archiver(self, username: str) -> TwitchArchive:
|
||||
"""
|
||||
Initialize a TwitchArchive instance for a streamer.
|
||||
|
||||
Args:
|
||||
username: Twitch username
|
||||
|
||||
Returns:
|
||||
TwitchArchive: Initialized archiver instance
|
||||
"""
|
||||
config = self.config_manager.load_streamer_config(username)
|
||||
archiver = TwitchArchive(config)
|
||||
return archiver
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
Main entry point for multi-streamer monitoring.
|
||||
|
||||
Monitors all enabled streamers (or a specific one if provided).
|
||||
"""
|
||||
print(f'\n{Fore.CYAN}{"=" * 70}{Style.RESET_ALL}')
|
||||
print(f'{Fore.CYAN}TWITCH ARCHIVE - Multi-Streamer Mode{Style.RESET_ALL}')
|
||||
print(f'{Fore.CYAN}{"=" * 70}{Style.RESET_ALL}\n')
|
||||
|
||||
# Get streamers to monitor
|
||||
streamers = self._get_streamers_to_monitor()
|
||||
|
||||
if not streamers:
|
||||
print(f'{Fore.RED}✗ No streamers configured or enabled{Style.RESET_ALL}')
|
||||
print(f'{Fore.CYAN}→ Create config files in config/streamers/{Style.RESET_ALL}')
|
||||
print(f'{Fore.CYAN}→ Or run with -u <username> to create a new config{Style.RESET_ALL}')
|
||||
sys.exit(1)
|
||||
|
||||
print(f'{Fore.GREEN}Monitoring {len(streamers)} streamer(s):{Style.RESET_ALL}')
|
||||
for streamer in streamers:
|
||||
print(f' • {Fore.CYAN}{streamer}{Style.RESET_ALL}')
|
||||
print()
|
||||
|
||||
# Initialize archivers for all streamers
|
||||
for username in streamers:
|
||||
try:
|
||||
archiver = self._initialize_archiver(username)
|
||||
|
||||
# Load environment and validate
|
||||
archiver._load_environment_variables()
|
||||
archiver._validate_username()
|
||||
archiver._initialize_paths()
|
||||
|
||||
self.archivers[username] = archiver
|
||||
print(f'{Fore.GREEN}✓ Initialized {username}{Style.RESET_ALL}')
|
||||
except Exception as e:
|
||||
print(f'{Fore.RED}✗ Failed to initialize {username}: {e}{Style.RESET_ALL}')
|
||||
|
||||
if not self.archivers:
|
||||
print(f'{Fore.RED}✗ No archivers could be initialized{Style.RESET_ALL}')
|
||||
sys.exit(1)
|
||||
|
||||
# Verify dependencies once (shared across all streamers)
|
||||
print(f'\n{Fore.CYAN}Verifying dependencies...{Style.RESET_ALL}')
|
||||
first_archiver = next(iter(self.archivers.values()))
|
||||
first_archiver._verify_dependencies()
|
||||
|
||||
# Print configuration summary for each streamer
|
||||
for username, archiver in self.archivers.items():
|
||||
archiver._print_configuration_summary()
|
||||
|
||||
print(f'\n{Fore.GREEN}🚀 Starting monitoring loop...{Style.RESET_ALL}\n')
|
||||
|
||||
# Start monitoring loop
|
||||
self._monitoring_loop()
|
||||
|
||||
def _monitoring_loop(self) -> None:
|
||||
"""
|
||||
Main monitoring loop for all streamers.
|
||||
|
||||
Checks each streamer's status and processes streams as needed.
|
||||
"""
|
||||
last_check = {}
|
||||
|
||||
while not self.shutdown_requested:
|
||||
current_time = time.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:
|
||||
last_check[username] = current_time
|
||||
|
||||
# Check stream status
|
||||
try:
|
||||
stream_info = 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}')
|
||||
|
||||
# Process the stream
|
||||
self._process_stream(archiver, stream_info, stream_id)
|
||||
|
||||
# Mark as processed
|
||||
archiver._mark_stream_as_processed(stream_id)
|
||||
else:
|
||||
# Not live - check for new VODs if needed
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f'{Fore.RED}[{username}] Error checking stream: {e}{Style.RESET_ALL}')
|
||||
|
||||
# Sleep briefly before next iteration
|
||||
time.sleep(1)
|
||||
|
||||
def _process_stream(self, archiver: TwitchArchive, stream_info: Dict[str, Any], stream_id: str) -> None:
|
||||
"""
|
||||
Process a detected stream for a specific archiver.
|
||||
|
||||
Args:
|
||||
archiver: The TwitchArchive instance
|
||||
stream_info: Stream information from API
|
||||
stream_id: Unique stream ID
|
||||
"""
|
||||
# Store stream data
|
||||
archiver.current_stream_data = {
|
||||
'stream_id': stream_id,
|
||||
'title': stream_info['title'],
|
||||
'started_at': stream_info['createdAt']
|
||||
}
|
||||
|
||||
# Generate timestamp and filename
|
||||
timestamp = datetime.now(timezone('UTC')).strftime("%Y%m%d_%Hh%Mm%Ss")
|
||||
filename_base = f"{PREFIX_LIVE}{archiver.username}_{timestamp}"
|
||||
|
||||
# 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}")
|
||||
|
||||
# Send notification
|
||||
archiver.send_notification(
|
||||
f"Stream Started - {archiver.username}",
|
||||
f"Recording: {stream_info['title']}"
|
||||
)
|
||||
|
||||
# Record livestream
|
||||
recording_successful = archiver._record_livestream(stream_info, live_raw_path)
|
||||
|
||||
if not recording_successful:
|
||||
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)
|
||||
|
||||
# Save metadata
|
||||
if archiver.downloadMETADATA == 1:
|
||||
archiver._save_metadata(stream_info, filename_base)
|
||||
|
||||
# Wait for VOD and download it
|
||||
if archiver.downloadVOD == 1 and archiver.vodTimeout > 0:
|
||||
# This would need the full VOD logic from loopcheck
|
||||
pass
|
||||
|
||||
# Upload to cloud if configured
|
||||
if archiver.uploadCloud == 1:
|
||||
archiver._upload_to_cloud(filename_base)
|
||||
|
||||
# Delete files if configured
|
||||
if archiver.deleteFiles == 1:
|
||||
archiver._delete_local_files(filename_base, live_raw_path, live_proc_path)
|
||||
|
||||
# Send completion notification
|
||||
archiver.send_notification(
|
||||
f"Stream Archived - {archiver.username}",
|
||||
f"Completed: {stream_info['title']}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# COMMAND-LINE INTERFACE
|
||||
# ============================================================================
|
||||
|
|
@ -1518,7 +1909,8 @@ def main(argv: list) -> None:
|
|||
Args:
|
||||
argv: Command-line arguments
|
||||
"""
|
||||
twitch_archive = TwitchArchive()
|
||||
specific_streamer = None
|
||||
use_legacy_mode = False
|
||||
|
||||
help_msg = f'''
|
||||
{Fore.CYAN}{"=" * 70}
|
||||
|
|
@ -1528,9 +1920,22 @@ TWITCH ARCHIVE - Automated Stream Recording & Archiving
|
|||
{Fore.GREEN}USAGE:{Style.RESET_ALL}
|
||||
python twitch-archive.py [OPTIONS]
|
||||
|
||||
{Fore.GREEN}MODES:{Style.RESET_ALL}
|
||||
• Multi-Streamer Mode (default):
|
||||
Monitor all enabled streamers from config/streamers/*.json
|
||||
|
||||
• Single-Streamer Mode:
|
||||
Use -u <username> to monitor only one streamer
|
||||
|
||||
• Legacy Mode:
|
||||
Uses config.json if it exists (deprecated)
|
||||
|
||||
{Fore.GREEN}OPTIONS:{Style.RESET_ALL}
|
||||
-h, --help Display this help information
|
||||
-u, --username <name> Twitch channel username to monitor
|
||||
-u, --username <name> Monitor only this Twitch channel
|
||||
--legacy Force legacy mode (use config.json)
|
||||
|
||||
{Fore.GREEN}LEGACY OPTIONS (when using --legacy):{Style.RESET_ALL}
|
||||
-q, --quality <qual> Stream quality: best/source, high/720p,
|
||||
medium/480p, low/360p, audio_only
|
||||
-a, --ttv-lol <0|1> Enable ad-blocking (1) or disable (0)
|
||||
|
|
@ -1542,9 +1947,15 @@ TWITCH ARCHIVE - Automated Stream Recording & Archiving
|
|||
-n, --notifications <0|1> Send email notifications
|
||||
|
||||
{Fore.YELLOW}TIPS:{Style.RESET_ALL}
|
||||
• Configure settings in config.json (copy from config.sample.json)
|
||||
• Create config/global.json for default settings
|
||||
• Create config/streamers/<username>.json for each streamer
|
||||
• Set enabled: true/false in each streamer config
|
||||
• Set up API credentials in .env file
|
||||
• Most users only need to edit config.json, no command-line args needed
|
||||
|
||||
{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 --legacy # Use old config.json mode
|
||||
|
||||
{Fore.CYAN}{"=" * 70}{Style.RESET_ALL}
|
||||
'''
|
||||
|
|
@ -1554,38 +1965,62 @@ 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="]
|
||||
"metadata=", "upload=", "delete=", "notifications=", "legacy"]
|
||||
)
|
||||
except getopt.GetoptError as e:
|
||||
print(f'{Fore.RED}Error: {e}{Style.RESET_ALL}\n')
|
||||
print(help_msg)
|
||||
sys.exit(2)
|
||||
|
||||
# Check if legacy mode is requested or if config.json exists (fallback)
|
||||
legacy_config_exists = os.path.exists(os.path.join(os.path.dirname(__file__), 'config.json'))
|
||||
|
||||
# Parse command line args
|
||||
legacy_overrides = {}
|
||||
for opt, arg in opts:
|
||||
if opt in ('-h', '--help'):
|
||||
print(help_msg)
|
||||
sys.exit(0)
|
||||
elif opt in ("-u", "--username"):
|
||||
twitch_archive.username = arg
|
||||
specific_streamer = arg
|
||||
elif opt == "--legacy":
|
||||
use_legacy_mode = True
|
||||
# Legacy options (only used in legacy mode)
|
||||
elif opt in ("-q", "--quality"):
|
||||
twitch_archive.quality = arg
|
||||
legacy_overrides['quality'] = arg
|
||||
elif opt in ("-a", "--ttv-lol"):
|
||||
twitch_archive.streamlink_ttvlol = int(arg)
|
||||
legacy_overrides['streamlink_ttvlol'] = int(arg)
|
||||
elif opt in ("-v", "--vod"):
|
||||
twitch_archive.downloadVOD = int(arg)
|
||||
legacy_overrides['downloadVOD'] = int(arg)
|
||||
elif opt in ("-c", "--chat"):
|
||||
twitch_archive.downloadCHAT = int(arg)
|
||||
legacy_overrides['downloadCHAT'] = int(arg)
|
||||
elif opt in ("-m", "--metadata"):
|
||||
twitch_archive.downloadMETADATA = int(arg)
|
||||
legacy_overrides['downloadMETADATA'] = int(arg)
|
||||
elif opt in ("-r", "--upload"):
|
||||
twitch_archive.uploadCloud = int(arg)
|
||||
legacy_overrides['uploadCloud'] = int(arg)
|
||||
elif opt in ("-d", "--delete"):
|
||||
twitch_archive.deleteFiles = int(arg)
|
||||
legacy_overrides['deleteFiles'] = int(arg)
|
||||
elif opt in ("-n", "--notifications"):
|
||||
twitch_archive.notifications = int(arg)
|
||||
legacy_overrides['notifications'] = int(arg)
|
||||
|
||||
# Start the archive system
|
||||
twitch_archive.run()
|
||||
# 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')):
|
||||
# Legacy mode: single streamer using config.json
|
||||
print(f'{Fore.YELLOW}⚠ Using legacy mode (config.json){Style.RESET_ALL}')
|
||||
print(f'{Fore.CYAN}→ Consider migrating to new config structure (config/global.json + config/streamers/*.json){Style.RESET_ALL}\n')
|
||||
|
||||
twitch_archive = TwitchArchive() # Loads from config.json
|
||||
|
||||
# Apply command-line overrides
|
||||
for key, value in legacy_overrides.items():
|
||||
setattr(twitch_archive, key, value)
|
||||
|
||||
# Start the archive system
|
||||
twitch_archive.run()
|
||||
else:
|
||||
# New multi-streamer mode
|
||||
manager = TwitchArchiveManager(specific_streamer=specific_streamer)
|
||||
manager.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue