Add Qobuz auth token login as CAPTCHA bypass alternative

Qobuz added reCAPTCHA to their login endpoint, blocking automated
email/password auth for new users. Token login lets users paste
their X-User-Auth-Token from the browser DevTools after logging in
manually. Added to both Connections and Downloads tabs with
instructions. Existing email/password flow completely unchanged.
Backend validates token via user/get API and saves the session
identically to email/password login.
This commit is contained in:
Broque Thomas 2026-04-08 10:37:38 -07:00
parent e1f7b8f5cc
commit 3d96752087
4 changed files with 196 additions and 0 deletions

View file

@ -446,6 +446,64 @@ class QobuzClient:
traceback.print_exc() traceback.print_exc()
return {'status': 'error', 'message': self._auth_error} return {'status': 'error', 'message': self._auth_error}
def login_with_token(self, token: str) -> Dict[str, Any]:
"""
Login to Qobuz with a user_auth_token pasted from the browser.
Bypasses email/password login (and any CAPTCHA) entirely.
"""
self._auth_error = None
try:
# Step 1: Extract app credentials if we don't have them
if not self.app_id or not self.app_secret:
if not self._extract_app_credentials():
self._auth_error = 'Could not extract Qobuz app credentials. Qobuz may have updated their web player.'
return {'status': 'error', 'message': self._auth_error}
# Step 2: Set the token and validate it
self.user_auth_token = token.strip()
self.session.headers['X-App-Id'] = self.app_id
self.session.headers['X-User-Auth-Token'] = self.user_auth_token
resp = self.session.get(
QOBUZ_API_BASE + 'user/get',
params={'user_id': 'me'},
timeout=15,
)
if resp.status_code != 200:
self.user_auth_token = None
self.session.headers.pop('X-User-Auth-Token', None)
self._auth_error = f'Invalid token (HTTP {resp.status_code})'
return {'status': 'error', 'message': self._auth_error}
data = resp.json()
self.user_info = data
# Check subscription
subscription = data.get('credential', {})
sub_label = subscription.get('label', 'Unknown')
# Save session
self._save_session()
display_name = data.get('display_name', data.get('email', 'unknown'))
logger.info(f"Qobuz token login successful: {display_name} (plan: {sub_label})")
return {
'status': 'success',
'message': f'Logged in as {display_name}',
'user': {
'display_name': display_name,
'subscription': sub_label,
'email': data.get('email', ''),
},
}
except Exception as e:
self._auth_error = str(e)
logger.error(f"Qobuz token login failed: {e}")
return {'status': 'error', 'message': self._auth_error}
def logout(self): def logout(self):
"""Clear Qobuz session.""" """Clear Qobuz session."""
self.user_auth_token = None self.user_auth_token = None

View file

@ -31465,6 +31465,28 @@ def qobuz_auth_login():
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/qobuz/auth/token', methods=['POST'])
def qobuz_auth_token():
"""Login to Qobuz with a pasted user_auth_token (bypasses CAPTCHA)."""
try:
data = request.get_json()
token = data.get('token', '').strip()
if not token:
return jsonify({"success": False, "error": "Auth token required"}), 400
qobuz = soulseek_client.qobuz
result = qobuz.login_with_token(token)
if result['status'] == 'success':
return jsonify({"success": True, **result})
else:
return jsonify({"success": False, "error": result.get('message', 'Token login failed')}), 400
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/qobuz/auth/status', methods=['GET']) @app.route('/api/qobuz/auth/status', methods=['GET'])
def qobuz_auth_status(): def qobuz_auth_status():
"""Check if Qobuz client is authenticated.""" """Check if Qobuz client is authenticated."""

View file

@ -4232,6 +4232,25 @@
<div class="setting-help-text" style="margin-top: 6px;"> <div class="setting-help-text" style="margin-top: 6px;">
Connects Qobuz for metadata enrichment (ISRC, labels, copyright). Also used for downloads if Qobuz is your download source. Connects Qobuz for metadata enrichment (ISRC, labels, copyright). Also used for downloads if Qobuz is your download source.
</div> </div>
<div class="callback-info" style="margin-top: 10px; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px;">
<div class="callback-help" style="margin-bottom: 8px;"><strong>Alternative: Auth Token</strong> — If email/password login fails (CAPTCHA), paste your token directly:</div>
</div>
<div class="form-group">
<label>Auth Token:</label>
<input type="password" id="qobuz-connection-token" class="form-input"
placeholder="Paste your Qobuz auth token here">
</div>
<div class="form-actions">
<button class="auth-button" id="qobuz-token-login-btn" onclick="loginQobuzWithToken()">
Connect with Token
</button>
<span id="qobuz-token-status" class="setting-help-text" style="margin-left: 8px;"></span>
</div>
<div class="callback-info">
<div class="callback-help">To get your token: log into <a href="https://play.qobuz.com" target="_blank" style="color: #4285f4;">play.qobuz.com</a>
→ DevTools (F12) → Network tab → find any request to <code>www.qobuz.com/api.json</code>
→ look in the request headers for <code>X-User-Auth-Token</code> → copy that value.</div>
</div>
</div> </div>
</div> </div>
@ -4789,6 +4808,23 @@
<div class="setting-help-text" style="margin-top: 6px;"> <div class="setting-help-text" style="margin-top: 6px;">
Requires a paid Qobuz subscription. Streams are DRM-free. Requires a paid Qobuz subscription. Streams are DRM-free.
</div> </div>
<div class="callback-info" style="margin-top: 10px; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px;">
<div class="callback-help" style="margin-bottom: 8px;"><strong>Alternative: Auth Token</strong> — If email/password login fails (CAPTCHA), paste your token directly:</div>
</div>
<input type="password" id="qobuz-download-token" class="form-input"
placeholder="Paste your Qobuz auth token here"
style="margin-bottom: 8px;">
<div class="form-actions">
<button class="auth-button" id="qobuz-download-token-btn" onclick="loginQobuzWithTokenFromDownloads()">
Connect with Token
</button>
<span id="qobuz-download-token-status" class="setting-help-text" style="margin-left: 8px;"></span>
</div>
<div class="callback-info">
<div class="callback-help">To get your token: log into <a href="https://play.qobuz.com" target="_blank" style="color: #4285f4;">play.qobuz.com</a>
→ DevTools (F12) → Network tab → find any request to <code>www.qobuz.com/api.json</code>
→ look for <code>X-User-Auth-Token</code> in the request headers → copy that value.</div>
</div>
</div> </div>
</div> </div>

View file

@ -7983,6 +7983,86 @@ async function loginQobuzFromConnections() {
} }
} }
async function loginQobuzWithToken() {
const btn = document.getElementById('qobuz-token-login-btn');
const statusEl = document.getElementById('qobuz-token-status');
const token = document.getElementById('qobuz-connection-token').value.trim();
if (!token) {
showToast('Please paste your Qobuz auth token', 'warning');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
if (statusEl) statusEl.textContent = '';
try {
const resp = await fetch('/api/qobuz/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await resp.json();
if (data.success) {
showToast('Qobuz connected via token!', 'success');
document.getElementById('qobuz-connection-token').value = '';
checkQobuzAuthStatus();
} else {
if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; }
showToast(data.error || 'Qobuz token login failed', 'error');
}
} catch (error) {
console.error('Qobuz token login error:', error);
if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; }
showToast('Failed to connect to Qobuz', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect with Token';
}
}
async function loginQobuzWithTokenFromDownloads() {
const btn = document.getElementById('qobuz-download-token-btn');
const statusEl = document.getElementById('qobuz-download-token-status');
const token = document.getElementById('qobuz-download-token').value.trim();
if (!token) {
showToast('Please paste your Qobuz auth token', 'warning');
return;
}
btn.disabled = true;
btn.textContent = 'Connecting...';
if (statusEl) statusEl.textContent = '';
try {
const resp = await fetch('/api/qobuz/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await resp.json();
if (data.success) {
showToast('Qobuz connected via token!', 'success');
document.getElementById('qobuz-download-token').value = '';
checkQobuzAuthStatus();
} else {
if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; }
showToast(data.error || 'Qobuz token login failed', 'error');
}
} catch (error) {
console.error('Qobuz token login error:', error);
if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; }
showToast('Failed to connect to Qobuz', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Connect with Token';
}
}
async function loginQobuz() { async function loginQobuz() {
const btn = document.getElementById('qobuz-login-btn'); const btn = document.getElementById('qobuz-login-btn');
const statusEl = document.getElementById('qobuz-auth-status'); const statusEl = document.getElementById('qobuz-auth-status');