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:
parent
e1f7b8f5cc
commit
3d96752087
4 changed files with 196 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue