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