User configurable youtube rate limiting and optional cookies for bot detection

This commit is contained in:
Broque Thomas 2026-03-05 10:50:06 -08:00
parent aef056cfcb
commit 75f9b7364a
5 changed files with 118 additions and 30 deletions

View file

@ -240,6 +240,10 @@ class ConfigManager:
"m3u_export": {
"enabled": False
},
"youtube": {
"cookies_browser": "", # "", "chrome", "firefox", "edge", "brave", "opera", "safari"
"download_delay": 3, # seconds between sequential downloads
},
"hydrabase": {
"url": "",
"api_key": "",

View file

@ -13,6 +13,7 @@ This client provides:
import sys
import os
import re
import time
import platform
import asyncio
import uuid
@ -111,6 +112,11 @@ class YouTubeClient:
# Callback for shutdown check (avoids circular imports)
self.shutdown_check = None
# Rate limiting — serialize YouTube downloads with delay
self._download_semaphore = threading.Semaphore(1)
self._download_delay = config_manager.get('youtube.download_delay', 3)
self._last_download_time = 0
def set_shutdown_check(self, check_callable):
"""Set a callback function to check for system shutdown"""
self.shutdown_check = check_callable
@ -153,6 +159,12 @@ class YouTubeClient:
'age_limit': None, # Don't skip age-restricted
}
# Cookie support — use browser cookies for YouTube auth
from config.settings import config_manager
cookies_browser = config_manager.get('youtube.cookies_browser', '')
if cookies_browser:
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
# Track current download progress (mirrors Soulseek transfer tracking)
self.current_download_id: Optional[str] = None
self.current_download_progress = {
@ -187,6 +199,17 @@ class YouTubeClient:
logger.error("yt-dlp is not installed")
return False
def reload_settings(self):
"""Reload YouTube settings from config (called when settings are saved)."""
from config.settings import config_manager
self._download_delay = config_manager.get('youtube.download_delay', 3)
cookies_browser = config_manager.get('youtube.cookies_browser', '')
if cookies_browser:
self.download_opts['cookiesfrombrowser'] = (cookies_browser,)
elif 'cookiesfrombrowser' in self.download_opts:
del self.download_opts['cookiesfrombrowser']
logger.info(f"🔄 YouTube settings reloaded (delay={self._download_delay}s, cookies={'enabled' if cookies_browser else 'disabled'})")
async def check_connection(self) -> bool:
"""
Test if YouTube is accessible by attempting a lightweight API call (async, Soulseek-compatible).
@ -568,6 +591,7 @@ class YouTubeClient:
loop = asyncio.get_event_loop()
def _search():
from config.settings import config_manager
ydl_opts = {
'quiet': True,
'no_warnings': True,
@ -583,6 +607,11 @@ class YouTubeClient:
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
}
# Add cookie support for search (avoids bot detection)
cookies_browser = config_manager.get('youtube.cookies_browser', '')
if cookies_browser:
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
# Search YouTube (max 50 results)
search_results = ydl.extract_info(f"ytsearch50:{query}", download=False)
@ -837,42 +866,54 @@ class YouTubeClient:
"""
Background thread worker for downloading YouTube videos.
Updates active_downloads dict with progress.
Serialized via semaphore with configurable delay between downloads.
"""
try:
# Update state to downloading
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'InProgress, Downloading' # Match Soulseek state
with self._download_semaphore:
# Enforce delay since last download completed
elapsed = time.time() - self._last_download_time
if self._last_download_time > 0 and elapsed < self._download_delay:
wait_time = self._download_delay - elapsed
logger.info(f"⏳ Rate limiting: waiting {wait_time:.1f}s before next YouTube download")
time.sleep(wait_time)
# Set current download ID for progress hook
self.current_download_id = download_id
# Perform actual download
file_path = self._download_sync(youtube_url, title)
# Clear current download ID
self.current_download_id = None
if file_path:
# Mark as completed/succeeded (match Soulseek state)
# Update state to downloading
with self._download_lock:
if download_id in self.active_downloads:
# IMPORTANT: Keep original filename for context lookup!
# The filename must match what was used to create the context entry
# We store the actual file path separately
self.active_downloads[download_id]['state'] = 'Completed, Succeeded' # Match Soulseek
self.active_downloads[download_id]['progress'] = 100.0
self.active_downloads[download_id]['file_path'] = file_path
# DO NOT update filename - keep original_filename for context matching
self.active_downloads[download_id]['state'] = 'InProgress, Downloading' # Match Soulseek state
logger.info(f"✅ YouTube download {download_id} completed: {file_path}")
else:
# Mark as errored
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'Errored'
# Set current download ID for progress hook
self.current_download_id = download_id
logger.error(f"❌ YouTube download {download_id} failed")
# Perform actual download
file_path = self._download_sync(youtube_url, title)
# Clear current download ID
self.current_download_id = None
# Record completion time for rate limiting
self._last_download_time = time.time()
if file_path:
# Mark as completed/succeeded (match Soulseek state)
with self._download_lock:
if download_id in self.active_downloads:
# IMPORTANT: Keep original filename for context lookup!
# The filename must match what was used to create the context entry
# We store the actual file path separately
self.active_downloads[download_id]['state'] = 'Completed, Succeeded' # Match Soulseek
self.active_downloads[download_id]['progress'] = 100.0
self.active_downloads[download_id]['file_path'] = file_path
# DO NOT update filename - keep original_filename for context matching
logger.info(f"✅ YouTube download {download_id} completed: {file_path}")
else:
# Mark as errored
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'Errored'
logger.error(f"❌ YouTube download {download_id} failed")
except Exception as e:
logger.error(f"❌ YouTube download thread failed for {download_id}: {e}")

View file

@ -2796,7 +2796,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'listenbrainz', 'acoustid', 'import', 'lossy_copy', 'ui_appearance']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'listenbrainz', 'acoustid', 'import', 'lossy_copy', 'ui_appearance', 'youtube']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
@ -2816,6 +2816,9 @@ def handle_settings():
navidrome_client.reload_config()
# Reload orchestrator settings (download source mode, hybrid_primary, etc.)
soulseek_client.reload_settings()
# Reload YouTube client settings (rate limiting, cookies)
if hasattr(soulseek_client, 'youtube'):
soulseek_client.youtube.reload_settings()
# FIX: Re-instantiate the global tidal_client to pick up new settings
tidal_client = TidalClient()
print("✅ Service clients re-initialized with new settings.")

View file

@ -3267,6 +3267,34 @@
</div>
</div>
<!-- YouTube Settings (shown when youtube or hybrid mode) -->
<div id="youtube-settings-container" style="display: none;">
<div class="form-group">
<label>YouTube Browser Cookies:</label>
<select id="youtube-cookies-browser" class="form-select">
<option value="">None (No cookies)</option>
<option value="chrome">Chrome</option>
<option value="firefox">Firefox</option>
<option value="edge">Edge</option>
<option value="brave">Brave</option>
<option value="opera">Opera</option>
<option value="safari">Safari</option>
</select>
<div class="setting-help-text">
If YouTube shows "Sign in to confirm you're not a bot", select your browser here.
SoulSync will use your browser's YouTube cookies to authenticate.
</div>
</div>
<div class="form-group">
<label>YouTube Download Delay:</label>
<input type="number" id="youtube-download-delay" min="0" max="30" step="1"
value="3" placeholder="3">
<div class="setting-help-text">
Seconds between YouTube downloads to avoid bot detection. Default: 3
</div>
</div>
</div>
<div class="form-group">
<label>Log Level:</label>
<select id="log-level-select" class="form-select" onchange="changeLogLevel()">

View file

@ -2845,6 +2845,10 @@ async function loadSettingsData() {
document.getElementById('youtube-min-confidence').value = settings.download_source?.youtube_min_confidence || 0.65;
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
// Populate YouTube settings
document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || '';
document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3;
// Update UI based on download source mode
updateDownloadSourceUI();
@ -3019,6 +3023,7 @@ function updateDownloadSourceUI() {
const mode = document.getElementById('download-source-mode').value;
const hybridContainer = document.getElementById('hybrid-settings-container');
const tidalContainer = document.getElementById('tidal-download-settings-container');
const youtubeContainer = document.getElementById('youtube-settings-container');
// Show hybrid settings only when hybrid mode is selected
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
@ -3026,6 +3031,9 @@ function updateDownloadSourceUI() {
// Show Tidal download settings when tidal mode is selected
tidalContainer.style.display = mode === 'tidal' ? 'block' : 'none';
// Show YouTube settings when youtube or hybrid mode is selected
youtubeContainer.style.display = (mode === 'youtube' || mode === 'hybrid') ? 'block' : 'none';
// Check Tidal download auth status when switching to tidal mode
if (mode === 'tidal') {
checkTidalDownloadAuthStatus();
@ -3573,6 +3581,10 @@ async function saveSettings(quiet = false) {
ui_appearance: {
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
accent_color: document.getElementById('accent-custom-color')?.value || '#1db954'
},
youtube: {
cookies_browser: document.getElementById('youtube-cookies-browser').value,
download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3,
}
};