User configurable youtube rate limiting and optional cookies for bot detection
This commit is contained in:
parent
aef056cfcb
commit
75f9b7364a
5 changed files with 118 additions and 30 deletions
|
|
@ -240,6 +240,10 @@ class ConfigManager:
|
||||||
"m3u_export": {
|
"m3u_export": {
|
||||||
"enabled": False
|
"enabled": False
|
||||||
},
|
},
|
||||||
|
"youtube": {
|
||||||
|
"cookies_browser": "", # "", "chrome", "firefox", "edge", "brave", "opera", "safari"
|
||||||
|
"download_delay": 3, # seconds between sequential downloads
|
||||||
|
},
|
||||||
"hydrabase": {
|
"hydrabase": {
|
||||||
"url": "",
|
"url": "",
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ This client provides:
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
import platform
|
import platform
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -111,6 +112,11 @@ class YouTubeClient:
|
||||||
# Callback for shutdown check (avoids circular imports)
|
# Callback for shutdown check (avoids circular imports)
|
||||||
self.shutdown_check = None
|
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):
|
def set_shutdown_check(self, check_callable):
|
||||||
"""Set a callback function to check for system shutdown"""
|
"""Set a callback function to check for system shutdown"""
|
||||||
self.shutdown_check = check_callable
|
self.shutdown_check = check_callable
|
||||||
|
|
@ -153,6 +159,12 @@ class YouTubeClient:
|
||||||
'age_limit': None, # Don't skip age-restricted
|
'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)
|
# Track current download progress (mirrors Soulseek transfer tracking)
|
||||||
self.current_download_id: Optional[str] = None
|
self.current_download_id: Optional[str] = None
|
||||||
self.current_download_progress = {
|
self.current_download_progress = {
|
||||||
|
|
@ -187,6 +199,17 @@ class YouTubeClient:
|
||||||
logger.error("yt-dlp is not installed")
|
logger.error("yt-dlp is not installed")
|
||||||
return False
|
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:
|
async def check_connection(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Test if YouTube is accessible by attempting a lightweight API call (async, Soulseek-compatible).
|
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()
|
loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
def _search():
|
def _search():
|
||||||
|
from config.settings import config_manager
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
'no_warnings': 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',
|
'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:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
# Search YouTube (max 50 results)
|
# Search YouTube (max 50 results)
|
||||||
search_results = ydl.extract_info(f"ytsearch50:{query}", download=False)
|
search_results = ydl.extract_info(f"ytsearch50:{query}", download=False)
|
||||||
|
|
@ -837,42 +866,54 @@ class YouTubeClient:
|
||||||
"""
|
"""
|
||||||
Background thread worker for downloading YouTube videos.
|
Background thread worker for downloading YouTube videos.
|
||||||
Updates active_downloads dict with progress.
|
Updates active_downloads dict with progress.
|
||||||
|
Serialized via semaphore with configurable delay between downloads.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Update state to downloading
|
with self._download_semaphore:
|
||||||
with self._download_lock:
|
# Enforce delay since last download completed
|
||||||
if download_id in self.active_downloads:
|
elapsed = time.time() - self._last_download_time
|
||||||
self.active_downloads[download_id]['state'] = 'InProgress, Downloading' # Match Soulseek state
|
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
|
# Update state to downloading
|
||||||
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)
|
|
||||||
with self._download_lock:
|
with self._download_lock:
|
||||||
if download_id in self.active_downloads:
|
if download_id in self.active_downloads:
|
||||||
# IMPORTANT: Keep original filename for context lookup!
|
self.active_downloads[download_id]['state'] = 'InProgress, Downloading' # Match Soulseek state
|
||||||
# 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}")
|
# Set current download ID for progress hook
|
||||||
else:
|
self.current_download_id = download_id
|
||||||
# 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")
|
# 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:
|
except Exception as e:
|
||||||
logger.error(f"❌ YouTube download thread failed for {download_id}: {e}")
|
logger.error(f"❌ YouTube download thread failed for {download_id}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -2796,7 +2796,7 @@ def handle_settings():
|
||||||
if 'active_media_server' in new_settings:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
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:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -2816,6 +2816,9 @@ def handle_settings():
|
||||||
navidrome_client.reload_config()
|
navidrome_client.reload_config()
|
||||||
# Reload orchestrator settings (download source mode, hybrid_primary, etc.)
|
# Reload orchestrator settings (download source mode, hybrid_primary, etc.)
|
||||||
soulseek_client.reload_settings()
|
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
|
# FIX: Re-instantiate the global tidal_client to pick up new settings
|
||||||
tidal_client = TidalClient()
|
tidal_client = TidalClient()
|
||||||
print("✅ Service clients re-initialized with new settings.")
|
print("✅ Service clients re-initialized with new settings.")
|
||||||
|
|
|
||||||
|
|
@ -3267,6 +3267,34 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label>Log Level:</label>
|
<label>Log Level:</label>
|
||||||
<select id="log-level-select" class="form-select" onchange="changeLogLevel()">
|
<select id="log-level-select" class="form-select" onchange="changeLogLevel()">
|
||||||
|
|
|
||||||
|
|
@ -2845,6 +2845,10 @@ async function loadSettingsData() {
|
||||||
document.getElementById('youtube-min-confidence').value = settings.download_source?.youtube_min_confidence || 0.65;
|
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';
|
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
|
// Update UI based on download source mode
|
||||||
updateDownloadSourceUI();
|
updateDownloadSourceUI();
|
||||||
|
|
||||||
|
|
@ -3019,6 +3023,7 @@ function updateDownloadSourceUI() {
|
||||||
const mode = document.getElementById('download-source-mode').value;
|
const mode = document.getElementById('download-source-mode').value;
|
||||||
const hybridContainer = document.getElementById('hybrid-settings-container');
|
const hybridContainer = document.getElementById('hybrid-settings-container');
|
||||||
const tidalContainer = document.getElementById('tidal-download-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
|
// Show hybrid settings only when hybrid mode is selected
|
||||||
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
|
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
|
||||||
|
|
@ -3026,6 +3031,9 @@ function updateDownloadSourceUI() {
|
||||||
// Show Tidal download settings when tidal mode is selected
|
// Show Tidal download settings when tidal mode is selected
|
||||||
tidalContainer.style.display = mode === 'tidal' ? 'block' : 'none';
|
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
|
// Check Tidal download auth status when switching to tidal mode
|
||||||
if (mode === 'tidal') {
|
if (mode === 'tidal') {
|
||||||
checkTidalDownloadAuthStatus();
|
checkTidalDownloadAuthStatus();
|
||||||
|
|
@ -3573,6 +3581,10 @@ async function saveSettings(quiet = false) {
|
||||||
ui_appearance: {
|
ui_appearance: {
|
||||||
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
|
accent_preset: document.getElementById('accent-preset')?.value || '#1db954',
|
||||||
accent_color: document.getElementById('accent-custom-color')?.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,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue