Refactor code structure for improved readability and maintainability
This commit is contained in:
parent
efb320eb05
commit
e078cada3b
11 changed files with 1640 additions and 1184 deletions
6
modules/__init__.py
Normal file
6
modules/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""
|
||||||
|
Twitch Archive Modules
|
||||||
|
Refactored components for the Twitch Archive system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = '2.0.0'
|
||||||
158
modules/config.py
Normal file
158
modules/config.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""
|
||||||
|
Configuration management for Twitch Archive.
|
||||||
|
|
||||||
|
Handles loading global and per-streamer configuration files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
from typing import Dict, Any
|
||||||
|
from colorama import Fore, Style
|
||||||
|
|
||||||
|
from .constants import DEFAULT_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
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.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
|
||||||
46
modules/constants.py
Normal file
46
modules/constants.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""
|
||||||
|
Constants and default configuration values for Twitch Archive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# API Endpoints
|
||||||
|
TWITCH_OAUTH_URL = "https://id.twitch.tv/oauth2/token"
|
||||||
|
TWITCH_API_URL = "https://api.twitch.tv/helix"
|
||||||
|
TWITCH_GQL_URL = "https://gql.twitch.tv/gql"
|
||||||
|
TWITCH_GQL_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
|
||||||
|
|
||||||
|
# File prefixes for different content types
|
||||||
|
PREFIX_LIVE = "LIVE_"
|
||||||
|
PREFIX_VOD = "VOD_"
|
||||||
|
PREFIX_CHAT = "CHAT_"
|
||||||
|
PREFIX_METADATA = "METADA_" # Note: keeping original typo for compatibility
|
||||||
|
|
||||||
|
# Default configuration values
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
'username': 'your_twitch_username',
|
||||||
|
'quality': 'best',
|
||||||
|
'root_path': 'archive',
|
||||||
|
'rclone_path': 'remote:path/to/streams',
|
||||||
|
'refresh': 60.0,
|
||||||
|
'streamlink_ttvlol': False,
|
||||||
|
'notifications': False,
|
||||||
|
'downloadMETADATA': True,
|
||||||
|
'downloadVOD': True,
|
||||||
|
'downloadCHAT': True,
|
||||||
|
'downloadLiveCHAT': True,
|
||||||
|
'vodTimeout': 300,
|
||||||
|
'uploadCloud': True,
|
||||||
|
'deleteFiles': False,
|
||||||
|
'onlyRaw': False,
|
||||||
|
'cleanRaw': True,
|
||||||
|
'hls_segments': 3,
|
||||||
|
'hls_segmentsVOD': 10,
|
||||||
|
# FFmpeg 8.0+ Enhancement Options
|
||||||
|
'ffmpeg_hwaccel': 'auto', # Hardware acceleration: 'auto', 'nvenc', 'qsv', 'amf', 'vaapi', 'none'
|
||||||
|
'ffmpeg_threads': 0, # Thread count (0 = auto-detect)
|
||||||
|
'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': True, # Enable error recovery for corrupted streams
|
||||||
|
'ffmpeg_faststart': True, # Enable faststart for MP4 (better streaming compatibility)
|
||||||
|
'ffmpeg_progress': False # Show encoding progress
|
||||||
|
}
|
||||||
281
modules/downloader.py
Normal file
281
modules/downloader.py
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
"""
|
||||||
|
VOD and chat downloading functionality using TwitchDownloaderCLI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from colorama import Fore, Style
|
||||||
|
|
||||||
|
from .utils import get_bin_path
|
||||||
|
|
||||||
|
|
||||||
|
class ContentDownloader:
|
||||||
|
"""Handles VOD and chat downloading using TwitchDownloaderCLI."""
|
||||||
|
|
||||||
|
def __init__(self, twitch_downloader_path: str, ffmpeg_path: str, config: dict):
|
||||||
|
"""
|
||||||
|
Initialize the content downloader.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
twitch_downloader_path: Path to TwitchDownloaderCLI executable
|
||||||
|
ffmpeg_path: Path to FFmpeg executable
|
||||||
|
config: Configuration dictionary
|
||||||
|
"""
|
||||||
|
self.twitch_downloader_path = twitch_downloader_path
|
||||||
|
self.ffmpeg_path = ffmpeg_path
|
||||||
|
self.quality = config.get('quality', 'best')
|
||||||
|
self.hls_segments_vod = config.get('hls_segmentsVOD', 10)
|
||||||
|
self.download_vod = config.get('downloadVOD', True)
|
||||||
|
self.download_chat = config.get('downloadCHAT', True)
|
||||||
|
self.download_live_chat = config.get('downloadLiveCHAT', True)
|
||||||
|
|
||||||
|
def download_vod(self, vod_info: Dict[str, Any], output_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Download VOD using TwitchDownloaderCLI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vod_info: VOD metadata from Twitch API
|
||||||
|
output_path: Path where the VOD will be saved
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if download succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
if not self.download_vod:
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f'\n{Fore.CYAN}Downloading VOD: {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:]
|
||||||
|
|
||||||
|
vod_url = f"https://www.twitch.tv/videos/{vod_id}"
|
||||||
|
print(f'{Fore.YELLOW}VOD URL: {vod_url}{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
bin_path = get_bin_path()
|
||||||
|
cmd = [
|
||||||
|
self.twitch_downloader_path,
|
||||||
|
'videodownload',
|
||||||
|
'-u', vod_url,
|
||||||
|
'-q', self.quality,
|
||||||
|
'-t', str(self.hls_segments_vod),
|
||||||
|
'--ffmpeg-path', self.ffmpeg_path,
|
||||||
|
'--temp-path', os.path.join(bin_path, 'temp'),
|
||||||
|
'--collision', 'Rename',
|
||||||
|
'-o', output_path
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.call(cmd)
|
||||||
|
if result == 0:
|
||||||
|
print(f'{Fore.GREEN}✓ VOD downloaded{Style.RESET_ALL}')
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f'{Fore.RED}✗ VOD download failed with exit code: {result}{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f'{Fore.RED}✗ VOD download failed: {str(e)}{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def download_chat_json(self, vod_id: str, json_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Download chat JSON for a VOD.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vod_id: VOD ID
|
||||||
|
json_path: Path to save chat JSON
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
# Remove 'v' prefix if present
|
||||||
|
if isinstance(vod_id, str) and vod_id.startswith('v'):
|
||||||
|
vod_id = vod_id[1:]
|
||||||
|
|
||||||
|
print(f'{Fore.YELLOW}Downloading chat JSON for VOD {vod_id}...{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.call([
|
||||||
|
self.twitch_downloader_path, '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
|
||||||
|
|
||||||
|
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}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'{Fore.RED}✗ Chat download failed: {str(e)}{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def render_chat(self, json_path: str, video_path: str, output_args: str) -> bool:
|
||||||
|
"""
|
||||||
|
Render chat JSON as a video.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
json_path: Path to chat JSON file
|
||||||
|
video_path: Path to save rendered chat video
|
||||||
|
output_args: FFmpeg output arguments for encoding
|
||||||
|
|
||||||
|
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 = get_bin_path()
|
||||||
|
|
||||||
|
# Chat rendering settings
|
||||||
|
chat_settings = [
|
||||||
|
'--background-color', '#FF111111',
|
||||||
|
'-w', '500',
|
||||||
|
'-h', '1080',
|
||||||
|
'--outline',
|
||||||
|
'-f', 'Arial',
|
||||||
|
'--font-size', '22',
|
||||||
|
'--update-rate', '1.0',
|
||||||
|
'--offline',
|
||||||
|
'--output-args', output_args,
|
||||||
|
'--ffmpeg-path', self.ffmpeg_path,
|
||||||
|
'--temp-path', os.path.join(bin_path, 'temp'),
|
||||||
|
'--collision', 'Rename'
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f'{Fore.YELLOW}Rendering chat video...{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
# Debug output
|
||||||
|
full_cmd = [self.twitch_downloader_path, 'chatrender', '-i', json_path, '-o', video_path] + chat_settings
|
||||||
|
print(f'{Fore.CYAN}DEBUG - Chat render command:{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.CYAN} Output args passed: {repr(output_args)}{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
result = subprocess.call(full_cmd)
|
||||||
|
|
||||||
|
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 download_and_render_chat(self, vod_info: Dict[str, Any], json_path: str,
|
||||||
|
video_path: str, output_args: 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
|
||||||
|
output_args: FFmpeg output arguments for encoding
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
if not self.download_chat:
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f'\n{Fore.CYAN}Downloading chat: {vod_info["title"]}{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
# Extract numeric VOD ID
|
||||||
|
vod_id = vod_info["id"]
|
||||||
|
|
||||||
|
# Download chat JSON
|
||||||
|
if not self.download_chat_json(vod_id, json_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Render chat video
|
||||||
|
return self.render_chat(json_path, video_path, output_args)
|
||||||
|
|
||||||
|
def start_live_chat_download(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 not self.download_live_chat:
|
||||||
|
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:]
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
self.twitch_downloader_path, '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, timeout: int = 300) -> 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
|
||||||
|
timeout: Maximum time to wait in seconds
|
||||||
|
|
||||||
|
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=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
|
||||||
237
modules/file_manager.py
Normal file
237
modules/file_manager.py
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""
|
||||||
|
Cloud storage and file management for Twitch Archive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
from typing import List
|
||||||
|
from colorama import Fore, Style
|
||||||
|
|
||||||
|
from .constants import PREFIX_LIVE, PREFIX_VOD, PREFIX_CHAT, PREFIX_METADATA
|
||||||
|
from .utils import get_bin_path
|
||||||
|
|
||||||
|
|
||||||
|
class FileManager:
|
||||||
|
"""Handles file operations, cloud uploads, and cleanup."""
|
||||||
|
|
||||||
|
def __init__(self, root_path: str, username: str, config: dict):
|
||||||
|
"""
|
||||||
|
Initialize the file manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root_path: Root directory for archives
|
||||||
|
username: Twitch username
|
||||||
|
config: Configuration dictionary
|
||||||
|
"""
|
||||||
|
self.root_path = pathlib.Path(root_path)
|
||||||
|
self.username = username
|
||||||
|
self.upload_cloud = config.get('uploadCloud', True)
|
||||||
|
self.delete_files = config.get('deleteFiles', False)
|
||||||
|
self.clean_raw = config.get('cleanRaw', True)
|
||||||
|
self.download_vod = config.get('downloadVOD', True)
|
||||||
|
self.download_chat = config.get('downloadCHAT', True)
|
||||||
|
self.download_metadata = config.get('downloadMETADATA', True)
|
||||||
|
self.rclone_path = config.get('rclone_path', 'remote:path')
|
||||||
|
|
||||||
|
# Initialize paths
|
||||||
|
self.raw_path = self.root_path / username / "video" / "raw"
|
||||||
|
self.video_path = self.root_path / username / "video"
|
||||||
|
self.chat_json_path = self.root_path / username / "chat" / "json"
|
||||||
|
self.chat_mp4_path = self.root_path / username / "chat"
|
||||||
|
self.metadata_path = self.root_path / username / "metadata"
|
||||||
|
self.log_file = self.root_path / ".log"
|
||||||
|
|
||||||
|
def initialize_directories(self) -> None:
|
||||||
|
"""Create all necessary directory structures."""
|
||||||
|
for path in [self.raw_path, self.video_path, self.chat_json_path,
|
||||||
|
self.chat_mp4_path, self.metadata_path]:
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create log file if it doesn't exist
|
||||||
|
if not self.log_file.exists():
|
||||||
|
self.log_file.touch()
|
||||||
|
|
||||||
|
def is_stream_processed(self, stream_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a stream has already been processed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream_id: Unique identifier for the stream
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if already processed, False otherwise
|
||||||
|
"""
|
||||||
|
with open(self.log_file, 'r', encoding='utf-8') as f:
|
||||||
|
return stream_id in f.read()
|
||||||
|
|
||||||
|
def mark_stream_processed(self, stream_id: str) -> None:
|
||||||
|
"""Add stream to log file to prevent re-processing."""
|
||||||
|
with open(self.log_file, 'a', encoding='utf-8') as f:
|
||||||
|
f.write(f"{stream_id}\n")
|
||||||
|
|
||||||
|
def save_metadata(self, vod_info: dict, filename_base: str) -> None:
|
||||||
|
"""
|
||||||
|
Save VOD metadata to JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vod_info: VOD metadata from Twitch API
|
||||||
|
filename_base: Base filename (without extension)
|
||||||
|
"""
|
||||||
|
if not self.download_metadata:
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata_path = self.metadata_path / f"{PREFIX_METADATA}{filename_base}.json"
|
||||||
|
|
||||||
|
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(vod_info, f, ensure_ascii=False, indent=4)
|
||||||
|
|
||||||
|
print(f'{Fore.GREEN}✓ Metadata saved{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
def clean_raw_file(self, raw_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Delete raw .ts file if configured.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_path: Path to raw file
|
||||||
|
"""
|
||||||
|
if self.clean_raw and os.path.exists(raw_path):
|
||||||
|
print(f'{Fore.YELLOW}Deleting raw .ts file...{Style.RESET_ALL}')
|
||||||
|
os.remove(raw_path)
|
||||||
|
|
||||||
|
def upload_to_cloud(self, filename_base: str, notification_callback=None) -> bool:
|
||||||
|
"""
|
||||||
|
Upload archived files to cloud storage using rclone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename_base: Base filename (without prefixes/extensions)
|
||||||
|
notification_callback: Optional callback to send notifications
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if upload succeeded or is disabled, False if failed
|
||||||
|
"""
|
||||||
|
if not self.upload_cloud:
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(f'\n{Fore.CYAN}Uploading to cloud storage...{Style.RESET_ALL}')
|
||||||
|
if notification_callback:
|
||||||
|
notification_callback(f'☁ Uploading - {filename_base}', 'Uploading files to cloud storage')
|
||||||
|
|
||||||
|
# Create list of files to upload
|
||||||
|
bin_path = get_bin_path()
|
||||||
|
upload_list_path = os.path.join(bin_path, 'temp', 'upload.txt')
|
||||||
|
|
||||||
|
# Ensure temp directory exists
|
||||||
|
os.makedirs(os.path.dirname(upload_list_path), exist_ok=True)
|
||||||
|
|
||||||
|
files_to_upload = [
|
||||||
|
f"{PREFIX_LIVE}{filename_base}.ts",
|
||||||
|
f"{PREFIX_LIVE}{filename_base}.mp4",
|
||||||
|
f"{PREFIX_LIVE}{filename_base}.mp3",
|
||||||
|
f"{PREFIX_VOD}{filename_base}.ts",
|
||||||
|
f"{PREFIX_VOD}{filename_base}.mp4",
|
||||||
|
f"{PREFIX_VOD}{filename_base}.mp3",
|
||||||
|
f"{PREFIX_METADATA}{filename_base}.json",
|
||||||
|
f"{PREFIX_CHAT}{filename_base}.json",
|
||||||
|
f"{PREFIX_CHAT}{filename_base}.mp4"
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(upload_list_path, 'w') as f:
|
||||||
|
f.write('\n'.join(files_to_upload))
|
||||||
|
|
||||||
|
# Run rclone
|
||||||
|
try:
|
||||||
|
result = subprocess.call([
|
||||||
|
'rclone', 'copy',
|
||||||
|
str(self.root_path.resolve()),
|
||||||
|
self.rclone_path,
|
||||||
|
'--include-from', upload_list_path
|
||||||
|
])
|
||||||
|
|
||||||
|
# Clean up upload list
|
||||||
|
if os.path.exists(upload_list_path):
|
||||||
|
os.remove(upload_list_path)
|
||||||
|
|
||||||
|
if result == 0:
|
||||||
|
print(f'{Fore.GREEN}✓ Upload complete{Style.RESET_ALL}')
|
||||||
|
if notification_callback:
|
||||||
|
notification_callback(f'✓ Upload Success - {filename_base}', 'All files uploaded successfully')
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f'{Fore.RED}✗ Upload failed (exit code: {result}){Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW}Files preserved locally due to upload failure{Style.RESET_ALL}')
|
||||||
|
if notification_callback:
|
||||||
|
notification_callback(f'✗ Upload Failed - {filename_base}',
|
||||||
|
f'Upload failed with code {result}. Files preserved locally.')
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'{Fore.RED}✗ Upload error: {str(e)}{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_local_files(self, filename_base: str, live_raw_path: str,
|
||||||
|
live_proc_path: str, notification_callback=None) -> None:
|
||||||
|
"""
|
||||||
|
Delete local archive files after successful upload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename_base: Base filename (without prefixes/extensions)
|
||||||
|
live_raw_path: Path to live raw file
|
||||||
|
live_proc_path: Path to live processed file
|
||||||
|
notification_callback: Optional callback to send notifications
|
||||||
|
"""
|
||||||
|
print(f'\n{Fore.RED}{"=" * 60}{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.RED}⚠ DELETING LOCAL FILES{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.RED}{"=" * 60}{Style.RESET_ALL}\n')
|
||||||
|
|
||||||
|
if notification_callback:
|
||||||
|
notification_callback(f'🗑 Deleting - {filename_base}',
|
||||||
|
'Deleting local files after successful upload')
|
||||||
|
|
||||||
|
files_to_delete: List[str] = []
|
||||||
|
|
||||||
|
# Live files
|
||||||
|
if not self.clean_raw 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.download_vod:
|
||||||
|
vod_raw = self.raw_path / f"{PREFIX_VOD}{filename_base}.ts"
|
||||||
|
vod_mp4 = self.video_path / f"{PREFIX_VOD}{filename_base}.mp4"
|
||||||
|
vod_mp3 = self.video_path / f"{PREFIX_VOD}{filename_base}.mp3"
|
||||||
|
|
||||||
|
if not self.clean_raw and vod_raw.exists():
|
||||||
|
files_to_delete.append(str(vod_raw))
|
||||||
|
if vod_mp4.exists():
|
||||||
|
files_to_delete.append(str(vod_mp4))
|
||||||
|
if vod_mp3.exists():
|
||||||
|
files_to_delete.append(str(vod_mp3))
|
||||||
|
|
||||||
|
# Chat files
|
||||||
|
if self.download_chat:
|
||||||
|
chat_json = self.chat_json_path / f"{PREFIX_CHAT}{filename_base}.json"
|
||||||
|
chat_mp4 = self.chat_mp4_path / f"{PREFIX_CHAT}{filename_base}.mp4"
|
||||||
|
|
||||||
|
if chat_json.exists():
|
||||||
|
files_to_delete.append(str(chat_json))
|
||||||
|
if chat_mp4.exists():
|
||||||
|
files_to_delete.append(str(chat_mp4))
|
||||||
|
|
||||||
|
# Metadata files
|
||||||
|
if self.download_metadata:
|
||||||
|
metadata = self.metadata_path / f"{PREFIX_METADATA}{filename_base}.json"
|
||||||
|
if metadata.exists():
|
||||||
|
files_to_delete.append(str(metadata))
|
||||||
|
|
||||||
|
# Delete all files
|
||||||
|
for filepath in files_to_delete:
|
||||||
|
try:
|
||||||
|
print(f'{Fore.RED} Deleting: {os.path.basename(filepath)}{Style.RESET_ALL}')
|
||||||
|
os.remove(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'{Fore.YELLOW} ⚠ Failed to delete {filepath}: {e}{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
print(f'{Fore.RED}\n✓ Cleanup complete{Style.RESET_ALL}')
|
||||||
68
modules/notifications.py
Normal file
68
modules/notifications.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""
|
||||||
|
Email notification functionality for Twitch Archive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import smtplib
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from colorama import Fore, Style
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationManager:
|
||||||
|
"""Handles email notifications via Gmail SMTP."""
|
||||||
|
|
||||||
|
def __init__(self, enabled: bool = False, username: str = ""):
|
||||||
|
"""
|
||||||
|
Initialize the notification manager.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enabled: Whether notifications are enabled
|
||||||
|
username: Streamer username for notification subject
|
||||||
|
"""
|
||||||
|
self.enabled = enabled
|
||||||
|
self.username = username
|
||||||
|
|
||||||
|
def send(self, subject: str, content: str) -> None:
|
||||||
|
"""
|
||||||
|
Send email notification via Gmail SMTP.
|
||||||
|
|
||||||
|
Only sends if notifications are enabled in configuration.
|
||||||
|
Requires SENDER, RECEIVER, and PASSWD in .env file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subject: Email subject line
|
||||||
|
content: Email body content
|
||||||
|
"""
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
sender = os.getenv("SENDER")
|
||||||
|
receiver = os.getenv("RECEIVER")
|
||||||
|
password = os.getenv("PASSWD")
|
||||||
|
|
||||||
|
if not all([sender, receiver, password]):
|
||||||
|
print(f'{Fore.YELLOW}⚠ Notification skipped: Missing email credentials in .env{Style.RESET_ALL}')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Construct email
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['From'] = sender
|
||||||
|
msg['To'] = receiver
|
||||||
|
msg['Subject'] = f"{self.username} - {subject}"
|
||||||
|
|
||||||
|
body = f"Stream: {self.username}\n\n{content}"
|
||||||
|
msg.attach(MIMEText(body, 'plain'))
|
||||||
|
|
||||||
|
# Send via Gmail SMTP
|
||||||
|
with smtplib.SMTP('smtp.gmail.com', 587) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(sender, password)
|
||||||
|
server.sendmail(sender, receiver, msg.as_string())
|
||||||
|
|
||||||
|
except socket.error as e:
|
||||||
|
print(f'{Fore.YELLOW}⚠ Notification failed: {str(e)}{Style.RESET_ALL}')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'{Fore.YELLOW}⚠ Notification error: {str(e)}{Style.RESET_ALL}')
|
||||||
171
modules/processor.py
Normal file
171
modules/processor.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""
|
||||||
|
Video/audio processing functionality using FFmpeg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from colorama import Fore, Style
|
||||||
|
|
||||||
|
from .utils import detect_hardware_acceleration, get_hwaccel_encoder
|
||||||
|
|
||||||
|
|
||||||
|
class StreamProcessor:
|
||||||
|
"""Handles video/audio processing using FFmpeg."""
|
||||||
|
|
||||||
|
def __init__(self, os_type: str, ffmpeg_path: str, config: dict):
|
||||||
|
"""
|
||||||
|
Initialize the stream processor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
os_type: Operating system type ('windows' or 'linux')
|
||||||
|
ffmpeg_path: Path to FFmpeg executable
|
||||||
|
config: Configuration dictionary with FFmpeg settings
|
||||||
|
"""
|
||||||
|
self.os_type = os_type
|
||||||
|
self.ffmpeg_path = ffmpeg_path
|
||||||
|
self.quality = config.get('quality', 'best')
|
||||||
|
self.only_raw = config.get('onlyRaw', False)
|
||||||
|
self.ffmpeg_threads = config.get('ffmpeg_threads', 0)
|
||||||
|
self.ffmpeg_audio_codec = config.get('ffmpeg_audio_codec', 'aac')
|
||||||
|
self.ffmpeg_audio_samplerate = config.get('ffmpeg_audio_samplerate', 48000)
|
||||||
|
self.ffmpeg_audio_bitrate = config.get('ffmpeg_audio_bitrate', '192k')
|
||||||
|
self.ffmpeg_error_recovery = config.get('ffmpeg_error_recovery', True)
|
||||||
|
self.ffmpeg_faststart = config.get('ffmpeg_faststart', True)
|
||||||
|
self.ffmpeg_progress = config.get('ffmpeg_progress', False)
|
||||||
|
self.hwaccel_type = detect_hardware_acceleration(
|
||||||
|
config.get('ffmpeg_hwaccel', 'auto'),
|
||||||
|
os_type
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_raw_stream(self, raw_path: str, output_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Process raw .ts file into mp4/mp3 using ffmpeg.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_path: Path to the raw .ts file
|
||||||
|
output_path: Path for the processed output file
|
||||||
|
"""
|
||||||
|
if not os.path.exists(raw_path):
|
||||||
|
print(f'{Fore.YELLOW}⚠ Raw file not found, skipping processing{Style.RESET_ALL}')
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.only_raw:
|
||||||
|
print(f'{Fore.CYAN}Keeping raw .ts file (onlyRaw mode){Style.RESET_ALL}')
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f'{Fore.YELLOW}Processing raw stream file...{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
# Build ffmpeg command based on quality
|
||||||
|
if self.quality == 'audio_only':
|
||||||
|
self._process_audio(raw_path, output_path)
|
||||||
|
else:
|
||||||
|
self._process_video(raw_path, output_path)
|
||||||
|
|
||||||
|
print(f'{Fore.GREEN}✓ Stream processed successfully{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
def _process_audio(self, raw_path: str, output_path: str) -> None:
|
||||||
|
"""Process audio-only stream."""
|
||||||
|
# Audio-only conversion with modern AAC encoding
|
||||||
|
cmd = [
|
||||||
|
self.ffmpeg_path,
|
||||||
|
'-i', raw_path,
|
||||||
|
'-vn', # No video
|
||||||
|
'-c:a', self.ffmpeg_audio_codec,
|
||||||
|
'-ar', str(self.ffmpeg_audio_samplerate),
|
||||||
|
'-ac', '2', # Stereo
|
||||||
|
'-b:a', self.ffmpeg_audio_bitrate,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add threading for faster encoding
|
||||||
|
if self.ffmpeg_threads > 0:
|
||||||
|
cmd.extend(['-threads', str(self.ffmpeg_threads)])
|
||||||
|
|
||||||
|
# Add faststart for better streaming compatibility
|
||||||
|
if self.ffmpeg_faststart and output_path.endswith(('.mp4', '.m4a')):
|
||||||
|
cmd.extend(['-movflags', '+faststart'])
|
||||||
|
|
||||||
|
cmd.append(output_path)
|
||||||
|
|
||||||
|
# Run FFmpeg
|
||||||
|
if self.ffmpeg_progress:
|
||||||
|
subprocess.call(cmd)
|
||||||
|
else:
|
||||||
|
subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
||||||
|
|
||||||
|
def _process_video(self, raw_path: str, output_path: str) -> None:
|
||||||
|
"""Process video stream."""
|
||||||
|
cmd = [
|
||||||
|
self.ffmpeg_path,
|
||||||
|
'-y', # Overwrite output file
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add hardware acceleration if enabled
|
||||||
|
if self.hwaccel_type and self.hwaccel_type != 'none':
|
||||||
|
print(f'{Fore.CYAN}Using hardware acceleration: {self.hwaccel_type}{Style.RESET_ALL}')
|
||||||
|
cmd.extend(['-hwaccel', 'auto'])
|
||||||
|
|
||||||
|
cmd.extend([
|
||||||
|
'-i', raw_path,
|
||||||
|
'-analyzeduration', '2147483647',
|
||||||
|
'-probesize', '2147483647',
|
||||||
|
])
|
||||||
|
|
||||||
|
# Threading support
|
||||||
|
if self.ffmpeg_threads >= 0:
|
||||||
|
cmd.extend(['-threads', str(self.ffmpeg_threads)])
|
||||||
|
|
||||||
|
# Error recovery options for corrupted streams
|
||||||
|
if self.ffmpeg_error_recovery:
|
||||||
|
cmd.extend([
|
||||||
|
'-fflags', '+genpts',
|
||||||
|
'-avoid_negative_ts', 'make_zero',
|
||||||
|
'-err_detect', 'ignore_err'
|
||||||
|
])
|
||||||
|
|
||||||
|
# Stream copy (fast, no re-encoding)
|
||||||
|
cmd.extend([
|
||||||
|
'-c:v', 'copy',
|
||||||
|
'-c:a', 'copy',
|
||||||
|
'-start_at_zero',
|
||||||
|
'-copyts',
|
||||||
|
])
|
||||||
|
|
||||||
|
# Add faststart for MP4 files
|
||||||
|
if self.ffmpeg_faststart and output_path.endswith('.mp4'):
|
||||||
|
cmd.extend(['-movflags', '+faststart'])
|
||||||
|
|
||||||
|
cmd.append(output_path)
|
||||||
|
|
||||||
|
# Run FFmpeg
|
||||||
|
if self.ffmpeg_progress:
|
||||||
|
subprocess.call(cmd)
|
||||||
|
else:
|
||||||
|
subprocess.call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
|
||||||
|
|
||||||
|
def build_chat_output_args(self) -> str:
|
||||||
|
"""
|
||||||
|
Build FFmpeg output arguments for chat rendering with hardware acceleration support.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Complete FFmpeg output arguments string with "{save_path}" placeholder
|
||||||
|
"""
|
||||||
|
if self.hwaccel_type and self.hwaccel_type != 'none':
|
||||||
|
encoder = get_hwaccel_encoder(self.hwaccel_type)
|
||||||
|
print(f'{Fore.CYAN}Using hardware-accelerated encoder for chat: {encoder}{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
if 'nvenc' in encoder:
|
||||||
|
result = f'-c:v {encoder} -preset p4 -cq 18 -pix_fmt yuv420p "{{save_path}}"'
|
||||||
|
elif 'qsv' in encoder:
|
||||||
|
result = f'-c:v {encoder} -global_quality 18 -pix_fmt yuv420p "{{save_path}}"'
|
||||||
|
elif 'amf' in encoder:
|
||||||
|
result = f'-c:v {encoder} -qp_i 18 -pix_fmt yuv420p "{{save_path}}"'
|
||||||
|
elif 'vaapi' in encoder:
|
||||||
|
result = f'-c:v {encoder} -qp 18 -pix_fmt yuv420p "{{save_path}}"'
|
||||||
|
else:
|
||||||
|
result = f'-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{{save_path}}"'
|
||||||
|
else:
|
||||||
|
# Default software encoding
|
||||||
|
result = f'-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{{save_path}}"'
|
||||||
|
|
||||||
|
print(f'{Fore.CYAN}DEBUG - Generated output_args: {result}{Style.RESET_ALL}')
|
||||||
|
return result
|
||||||
105
modules/recorder.py
Normal file
105
modules/recorder.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""
|
||||||
|
Live stream recording functionality for Twitch Archive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from colorama import Fore, Style
|
||||||
|
|
||||||
|
|
||||||
|
class StreamRecorder:
|
||||||
|
"""Handles live stream recording using streamlink."""
|
||||||
|
|
||||||
|
def __init__(self, username: str, quality: str, refresh: float,
|
||||||
|
hls_segments: int, streamlink_ttvlol: bool, shutdown_callback=None):
|
||||||
|
"""
|
||||||
|
Initialize the stream recorder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Twitch username to record
|
||||||
|
quality: Stream quality (e.g., 'best', '1080p60', 'audio_only')
|
||||||
|
refresh: Retry interval in seconds
|
||||||
|
hls_segments: Number of parallel HLS segments to download
|
||||||
|
streamlink_ttvlol: Enable ttv-lol proxy (deprecated)
|
||||||
|
shutdown_callback: Callable to check if shutdown was requested
|
||||||
|
"""
|
||||||
|
self.username = username
|
||||||
|
self.quality = quality
|
||||||
|
self.refresh = refresh
|
||||||
|
self.hls_segments = hls_segments
|
||||||
|
self.streamlink_ttvlol = streamlink_ttvlol
|
||||||
|
self.shutdown_callback = shutdown_callback
|
||||||
|
self.current_process = None
|
||||||
|
|
||||||
|
def record(self, stream_info: Dict[str, Any], output_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Record a live Twitch stream using streamlink.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream_info: Stream metadata from Twitch API
|
||||||
|
output_path: Path where the raw .ts file will be saved
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if recording completed normally, False if interrupted
|
||||||
|
"""
|
||||||
|
print(f'\n{Fore.CYAN}{"=" * 60}{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.GREEN}🔴 STREAM STARTED: {stream_info["title"]}{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.CYAN}{"=" * 60}{Style.RESET_ALL}\n')
|
||||||
|
|
||||||
|
# Build streamlink command
|
||||||
|
cmd = [
|
||||||
|
'streamlink',
|
||||||
|
f'twitch.tv/{self.username}',
|
||||||
|
self.quality,
|
||||||
|
'--hls-live-restart',
|
||||||
|
'--retry-streams', str(int(self.refresh)),
|
||||||
|
'--force',
|
||||||
|
'-o', output_path
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add segment threads for faster downloads (requires streamlink 5.0+)
|
||||||
|
if self.hls_segments > 1:
|
||||||
|
cmd.extend(['--stream-segment-threads', str(self.hls_segments)])
|
||||||
|
|
||||||
|
# Add ad-blocking if enabled (deprecated warning)
|
||||||
|
if self.streamlink_ttvlol:
|
||||||
|
print(f'{Fore.YELLOW}⚠ Warning: ttv-lol proxy option is deprecated in newer streamlink versions{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW} Consider disabling streamlink_ttvlol in config or using alternative methods{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
# Add authentication if available
|
||||||
|
oauth_token = os.getenv("OAUTH-PRIVATE-TOKEN", "")
|
||||||
|
if oauth_token and oauth_token != "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx":
|
||||||
|
cmd.extend(['--twitch-api-header', f'Authorization=OAuth {oauth_token}'])
|
||||||
|
|
||||||
|
# Show command being executed (hide OAuth token for security)
|
||||||
|
cmd_display = [c if 'OAuth' not in str(c) else 'Authorization=OAuth [HIDDEN]' for c in cmd]
|
||||||
|
print(f'{Fore.CYAN}Command: {" ".join(cmd_display)}{Style.RESET_ALL}')
|
||||||
|
|
||||||
|
# Record the stream (this blocks until stream ends)
|
||||||
|
print(f'{Fore.YELLOW}Recording stream...{Style.RESET_ALL}')
|
||||||
|
try:
|
||||||
|
self.current_process = subprocess.Popen(cmd)
|
||||||
|
return_code = self.current_process.wait()
|
||||||
|
self.current_process = None
|
||||||
|
|
||||||
|
if self.shutdown_callback and self.shutdown_callback():
|
||||||
|
print(f'{Fore.YELLOW}✓ Recording stopped by user{Style.RESET_ALL}')
|
||||||
|
# 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
|
||||||
|
except Exception as e:
|
||||||
|
self.current_process = None
|
||||||
|
print(f'{Fore.RED}✗ Recording error: {str(e)}{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the current recording process."""
|
||||||
|
if self.current_process:
|
||||||
|
try:
|
||||||
|
self.current_process.terminate()
|
||||||
|
print(f'{Fore.YELLOW}Stopping recording process...{Style.RESET_ALL}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
140
modules/stream_monitor.py
Normal file
140
modules/stream_monitor.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""
|
||||||
|
Stream monitoring and API interaction for Twitch Archive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
import requests
|
||||||
|
from colorama import Fore, Style
|
||||||
|
|
||||||
|
from .constants import TWITCH_OAUTH_URL, TWITCH_API_URL, TWITCH_GQL_URL, TWITCH_GQL_CLIENT_ID
|
||||||
|
|
||||||
|
|
||||||
|
class StreamMonitor:
|
||||||
|
"""Handles Twitch API interactions for monitoring stream status."""
|
||||||
|
|
||||||
|
def __init__(self, username: str):
|
||||||
|
"""
|
||||||
|
Initialize the stream monitor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Twitch username to monitor
|
||||||
|
"""
|
||||||
|
self.username = username
|
||||||
|
self._oauth_token = None
|
||||||
|
|
||||||
|
def get_oauth_token(self) -> str:
|
||||||
|
"""
|
||||||
|
Get OAuth token from Twitch API.
|
||||||
|
|
||||||
|
Uses CLIENT-ID and CLIENT-SECRET from environment variables.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: OAuth access token
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If authentication fails
|
||||||
|
"""
|
||||||
|
if self._oauth_token:
|
||||||
|
return self._oauth_token
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{TWITCH_OAUTH_URL}?client_id={os.getenv('CLIENT-ID')}&client_secret={os.getenv('CLIENT-SECRET')}&grant_type=client_credentials"
|
||||||
|
response = requests.post(url, timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
self._oauth_token = response.json()['access_token']
|
||||||
|
return self._oauth_token
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f'{Fore.RED}✗ ERROR: Failed to authenticate with Twitch API{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW} {str(e)}{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.CYAN} → Check your CLIENT-ID and CLIENT-SECRET in the .env file{Style.RESET_ALL}')
|
||||||
|
sys.exit(1)
|
||||||
|
except KeyError:
|
||||||
|
print(f'{Fore.RED}✗ ERROR: Invalid response from Twitch API{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.CYAN} → Verify your CLIENT-ID and CLIENT-SECRET are correct{Style.RESET_ALL}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def validate_username(self) -> bool:
|
||||||
|
"""
|
||||||
|
Validate that the configured Twitch username exists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if username exists, False otherwise
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If username is invalid or doesn't exist
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f'{TWITCH_API_URL}/users?login={self.username}'
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self.get_oauth_token()}",
|
||||||
|
"Client-ID": os.getenv('CLIENT-ID')
|
||||||
|
}
|
||||||
|
response = requests.get(url, headers=headers, timeout=15)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if not data.get('data'):
|
||||||
|
print(f'{Fore.RED}✗ ERROR: Twitch user "{self.username}" not found{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.CYAN} → Check the username in your config file{Style.RESET_ALL}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f'{Fore.GREEN}✓ Username "{self.username}" validated{Style.RESET_ALL}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f'{Fore.RED}✗ ERROR: Could not validate username{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW} {str(e)}{Style.RESET_ALL}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def check_stream_status(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Check if the configured user is currently live.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Stream information if live, None if offline
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If API request fails
|
||||||
|
"""
|
||||||
|
query = f'query{{user(login: "{self.username}") {{stream{{archiveVideo{{id}}title createdAt}}}}}}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
TWITCH_GQL_URL,
|
||||||
|
json={'query': query},
|
||||||
|
headers={"Client-ID": TWITCH_GQL_CLIENT_ID},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f'{Fore.RED}✗ ERROR: Failed to check stream status{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW} {str(e)}{Style.RESET_ALL}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def get_latest_vod(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get the most recent VOD for the configured user.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: VOD information, or None if no VODs found
|
||||||
|
"""
|
||||||
|
query = f'query {{user(login: "{self.username}") {{videos(first: 1) {{edges {{node {{id title description recordedAt lengthSeconds animatedPreviewURL previewThumbnailURL(height: 1280, width: 720) thumbnailURLs(height: 1280, width: 720)}}}}}}}}}}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
TWITCH_GQL_URL,
|
||||||
|
json={'query': query},
|
||||||
|
headers={"Client-ID": TWITCH_GQL_CLIENT_ID},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f'{Fore.YELLOW}⚠ Warning: Could not fetch latest VOD{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW} {str(e)}{Style.RESET_ALL}')
|
||||||
|
return None
|
||||||
231
modules/utils.py
Normal file
231
modules/utils.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""
|
||||||
|
Utility functions and helpers for Twitch Archive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
from colorama import Fore, Style
|
||||||
|
|
||||||
|
|
||||||
|
def detect_operating_system() -> str:
|
||||||
|
"""
|
||||||
|
Detect the current operating system.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 'windows' or 'linux'
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SystemExit: If OS is not supported
|
||||||
|
"""
|
||||||
|
if sys.platform.startswith('win32'):
|
||||||
|
return 'windows'
|
||||||
|
elif sys.platform.startswith('linux'):
|
||||||
|
return 'linux'
|
||||||
|
else:
|
||||||
|
print(f'{Fore.RED}✗ ERROR: Unsupported operating system: {sys.platform}{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW} This script only supports Windows and Linux{Style.RESET_ALL}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_bin_path() -> str:
|
||||||
|
"""Get the path to the bin directory containing external tools."""
|
||||||
|
return str(pathlib.Path(__file__).parent.parent.resolve() / "bin")
|
||||||
|
|
||||||
|
|
||||||
|
def get_ffmpeg_executable(os_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Get the platform-specific ffmpeg executable path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
os_type: Operating system type ('windows' or 'linux')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Path to ffmpeg executable
|
||||||
|
"""
|
||||||
|
bin_path = get_bin_path()
|
||||||
|
if os_type == 'windows':
|
||||||
|
return os.path.join(bin_path, 'ffmpeg.exe')
|
||||||
|
return os.path.join(bin_path, 'ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
|
def get_twitch_downloader_executable(os_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Get the platform-specific TwitchDownloaderCLI executable path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
os_type: Operating system type ('windows' or 'linux')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Path to TwitchDownloaderCLI executable
|
||||||
|
"""
|
||||||
|
bin_path = get_bin_path()
|
||||||
|
if os_type == 'windows':
|
||||||
|
return os.path.join(bin_path, 'TwitchDownloaderCLI.exe')
|
||||||
|
return os.path.join(bin_path, 'TwitchDownloaderCLI')
|
||||||
|
|
||||||
|
|
||||||
|
def get_unique_filename(filepath: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unique filename by appending a counter if file already exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: The desired file path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A unique file path (original or with _N suffix)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
If 'video.mp4' exists, returns 'video_1.mp4'
|
||||||
|
If 'video_1.mp4' also exists, returns 'video_2.mp4'
|
||||||
|
"""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
# Split into components
|
||||||
|
directory = os.path.dirname(filepath)
|
||||||
|
filename = os.path.basename(filepath)
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
|
||||||
|
# Find next available counter
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
new_filepath = os.path.join(directory, f"{name}_{counter}{ext}")
|
||||||
|
if not os.path.exists(new_filepath):
|
||||||
|
return new_filepath
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
def verify_streamlink() -> bool:
|
||||||
|
"""
|
||||||
|
Verify that streamlink is available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if streamlink is available, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['streamlink', '--version'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
version = result.stdout.strip().split()[1] if len(result.stdout.split()) > 1 else 'unknown'
|
||||||
|
print(f'{Fore.GREEN}✓ Streamlink v{version} found{Style.RESET_ALL}')
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
raise FileNotFoundError()
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, IndexError):
|
||||||
|
print(f'{Fore.RED}✗ ERROR: Streamlink not found{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.CYAN} → Install streamlink: pip install streamlink{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.CYAN} → Or download from: https://streamlink.github.io/{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_ffmpeg(os_type: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verify that ffmpeg is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
os_type: Operating system type ('windows' or 'linux')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if ffmpeg is available, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ffmpeg_path = get_ffmpeg_executable(os_type)
|
||||||
|
if os.path.exists(ffmpeg_path):
|
||||||
|
print(f'{Fore.GREEN}✓ FFmpeg found at {ffmpeg_path}{Style.RESET_ALL}')
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f'{Fore.YELLOW}⚠ Warning: FFmpeg not found at {ffmpeg_path}{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW} → Download FFmpeg and place it in the bin/ folder{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f'{Fore.YELLOW}⚠ Warning: Could not verify FFmpeg: {e}{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_twitch_downloader(os_type: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verify that TwitchDownloaderCLI is available.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
os_type: Operating system type ('windows' or 'linux')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if TwitchDownloaderCLI is available, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
downloader_path = get_twitch_downloader_executable(os_type)
|
||||||
|
if os.path.exists(downloader_path):
|
||||||
|
print(f'{Fore.GREEN}✓ TwitchDownloaderCLI found{Style.RESET_ALL}')
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f'{Fore.YELLOW}⚠ Warning: TwitchDownloaderCLI not found at {downloader_path}{Style.RESET_ALL}')
|
||||||
|
print(f'{Fore.YELLOW} → Download from: https://github.com/lay295/TwitchDownloader/releases{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f'{Fore.YELLOW}⚠ Warning: Could not verify TwitchDownloaderCLI: {e}{Style.RESET_ALL}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_hardware_acceleration(hwaccel_config: str, os_type: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Detect available hardware acceleration based on config and system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hwaccel_config: Hardware acceleration configuration ('auto', 'nvenc', 'qsv', 'amf', 'vaapi', 'none')
|
||||||
|
os_type: Operating system type ('windows' or 'linux')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Hardware acceleration type or None
|
||||||
|
"""
|
||||||
|
# If user explicitly set to 'none', disable hardware acceleration
|
||||||
|
if hwaccel_config == 'none':
|
||||||
|
return 'none'
|
||||||
|
|
||||||
|
# If user specified a particular type, use it
|
||||||
|
if hwaccel_config in ['nvenc', 'qsv', 'amf', 'vaapi']:
|
||||||
|
return hwaccel_config
|
||||||
|
|
||||||
|
# Auto-detect: try to determine available hardware
|
||||||
|
if hwaccel_config == 'auto':
|
||||||
|
# On Windows, NVIDIA is most common
|
||||||
|
if os_type == 'windows':
|
||||||
|
# Could check for nvidia-smi, but just return 'auto' for ffmpeg to decide
|
||||||
|
return 'auto'
|
||||||
|
else:
|
||||||
|
# On Linux, VAAPI is common for Intel/AMD, or NVENC for NVIDIA
|
||||||
|
# Let ffmpeg auto-detect
|
||||||
|
return 'auto'
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_hwaccel_encoder(hwaccel_type: str) -> str:
|
||||||
|
"""
|
||||||
|
Get the appropriate hardware-accelerated encoder for the given acceleration type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hwaccel_type: Type of hardware acceleration ('nvenc', 'qsv', 'amf', 'vaapi', 'auto', 'none')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: FFmpeg encoder name (e.g., 'h264_nvenc', 'libx264')
|
||||||
|
"""
|
||||||
|
encoder_map = {
|
||||||
|
'nvenc': 'h264_nvenc', # NVIDIA
|
||||||
|
'qsv': 'h264_qsv', # Intel Quick Sync
|
||||||
|
'amf': 'h264_amf', # AMD
|
||||||
|
'vaapi': 'h264_vaapi', # Linux VA-API
|
||||||
|
}
|
||||||
|
|
||||||
|
if hwaccel_type in encoder_map:
|
||||||
|
return encoder_map[hwaccel_type]
|
||||||
|
elif hwaccel_type == 'auto':
|
||||||
|
# Try NVENC first (most common), fall back to libx264
|
||||||
|
# In real usage, auto will attempt to use what's available
|
||||||
|
return 'h264_nvenc'
|
||||||
|
else:
|
||||||
|
return 'libx264' # Software encoding fallback
|
||||||
1385
twitch-archive.py
1385
twitch-archive.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue