fix bandcamp login
This commit is contained in:
@@ -251,13 +251,15 @@ function SpotifySection() {
|
|||||||
// ── Bandcamp credentials ──────────────────────────────────────────────────────
|
// ── Bandcamp credentials ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function BandcampSection() {
|
function BandcampSection() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [pwdSet, setPwdSet] = useState(false);
|
const [cookies, setCookies] = useState('');
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [pwdSet, setPwdSet] = useState(false);
|
||||||
const [saveState, setSaveState] = useState('idle');
|
const [cookiesSet, setCookiesSet] = useState(false);
|
||||||
const [testState, setTestState] = useState('idle');
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [testMsg, setTestMsg] = useState('');
|
const [saveState, setSaveState] = useState('idle');
|
||||||
|
const [testState, setTestState] = useState('idle');
|
||||||
|
const [testMsg, setTestMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/bandcamp/credentials')
|
fetch('/api/bandcamp/credentials')
|
||||||
@@ -265,6 +267,7 @@ function BandcampSection() {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
setEmail(data.email || '');
|
setEmail(data.email || '');
|
||||||
setPwdSet(!!data.password_set);
|
setPwdSet(!!data.password_set);
|
||||||
|
setCookiesSet(!!data.cookies_set);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
})
|
})
|
||||||
.catch(() => setLoaded(true));
|
.catch(() => setLoaded(true));
|
||||||
@@ -275,6 +278,7 @@ function BandcampSection() {
|
|||||||
try {
|
try {
|
||||||
const body = { email };
|
const body = { email };
|
||||||
if (password) body.password = password;
|
if (password) body.password = password;
|
||||||
|
if (cookies) body.cookies = cookies;
|
||||||
const r = await fetch('/api/bandcamp/credentials', {
|
const r = await fetch('/api/bandcamp/credentials', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -282,16 +286,26 @@ function BandcampSection() {
|
|||||||
});
|
});
|
||||||
if (!r.ok) throw new Error();
|
if (!r.ok) throw new Error();
|
||||||
setSaveState('ok');
|
setSaveState('ok');
|
||||||
if (password) setPwdSet(true);
|
if (password) { setPwdSet(true); setPassword(''); }
|
||||||
setPassword('');
|
if (cookies) { setCookiesSet(true); setCookies(''); }
|
||||||
} catch {
|
} catch {
|
||||||
setSaveState('error');
|
setSaveState('error');
|
||||||
}
|
}
|
||||||
setTimeout(() => setSaveState('idle'), 4000);
|
setTimeout(() => setSaveState('idle'), 4000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const testLogin = async () => {
|
const clearCookies = async () => {
|
||||||
if (email || password) await save();
|
try {
|
||||||
|
const r = await fetch('/api/bandcamp/credentials', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cookies: '' }),
|
||||||
|
});
|
||||||
|
if (r.ok) { setCookiesSet(false); setCookies(''); }
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
setTestState('testing');
|
setTestState('testing');
|
||||||
setTestMsg('');
|
setTestMsg('');
|
||||||
try {
|
try {
|
||||||
@@ -299,8 +313,8 @@ function BandcampSection() {
|
|||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
setTestState(data.ok ? 'ok' : 'error');
|
setTestState(data.ok ? 'ok' : 'error');
|
||||||
setTestMsg(data.ok
|
setTestMsg(data.ok
|
||||||
? `Logged in${data.username ? ` as ${data.username}` : ''}`
|
? `Connected${data.username ? ` as ${data.username}` : ''}`
|
||||||
: (data.error || 'Login failed'));
|
: (data.error || 'Connection failed'));
|
||||||
} catch {
|
} catch {
|
||||||
setTestState('error');
|
setTestState('error');
|
||||||
setTestMsg('Could not reach bandcamp-api service');
|
setTestMsg('Could not reach bandcamp-api service');
|
||||||
@@ -310,10 +324,42 @@ function BandcampSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="settings-row-hint" style={{ marginBottom: 10 }}>
|
||||||
|
Session cookies bypass Bandcamp's login captcha and are used first when saved.
|
||||||
|
Log in at{' '}
|
||||||
|
<a href="https://bandcamp.com" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>
|
||||||
|
bandcamp.com
|
||||||
|
</a>
|
||||||
|
{' '}in your browser, open DevTools (F12) → Network tab → click any bandcamp.com
|
||||||
|
request → Request Headers → copy the <code>Cookie</code> value and paste it below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-row" style={{ alignItems: 'flex-start' }}>
|
||||||
|
<div className="settings-row-left" style={{ paddingTop: 4 }}>
|
||||||
|
<div className="settings-row-label">Session Cookies</div>
|
||||||
|
<div className="settings-row-hint">
|
||||||
|
{!loaded ? '…' : cookiesSet
|
||||||
|
? <span>Saved — <button onClick={clearCookies} style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', padding: 0, fontSize: 'inherit' }}>clear</button></span>
|
||||||
|
: 'Not saved'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="settings-input"
|
||||||
|
style={{ height: 60, resize: 'vertical', fontFamily: 'monospace', fontSize: 11 }}
|
||||||
|
value={cookies}
|
||||||
|
onChange={e => setCookies(e.target.value)}
|
||||||
|
placeholder={cookiesSet ? '(saved — paste new value to replace)' : 'client_id=…; csrf_token=…; js_account_details=…'}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-row-hint" style={{ margin: '10px 0 6px', opacity: 0.55 }}>
|
||||||
|
Email and password are used as fallback when no cookies are saved.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-row">
|
<div className="settings-row">
|
||||||
<div className="settings-row-left">
|
<div className="settings-row-left">
|
||||||
<div className="settings-row-label">Email</div>
|
<div className="settings-row-label">Email</div>
|
||||||
<div className="settings-row-hint">Bandcamp account email</div>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
className="settings-input"
|
className="settings-input"
|
||||||
@@ -329,7 +375,7 @@ function BandcampSection() {
|
|||||||
<div className="settings-row-left">
|
<div className="settings-row-left">
|
||||||
<div className="settings-row-label">Password</div>
|
<div className="settings-row-label">Password</div>
|
||||||
<div className="settings-row-hint">
|
<div className="settings-row-hint">
|
||||||
{loaded && !pwdSet ? 'Not saved yet' : loaded ? 'Saved (leave blank to keep)' : '…'}
|
{loaded && !pwdSet ? 'Not saved' : loaded ? 'Saved (leave blank to keep)' : '…'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -345,13 +391,13 @@ function BandcampSection() {
|
|||||||
<button className="settings-action-btn" onClick={save} disabled={saveState === 'saving'}>
|
<button className="settings-action-btn" onClick={save} disabled={saveState === 'saving'}>
|
||||||
{saveState === 'saving' ? 'Saving…' : 'Save'}
|
{saveState === 'saving' ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
<button className="settings-action-btn" onClick={testLogin} disabled={testState === 'testing'}>
|
<button className="settings-action-btn" onClick={testConnection} disabled={testState === 'testing'}>
|
||||||
{testState === 'testing' ? 'Testing…' : 'Test Login'}
|
{testState === 'testing' ? 'Testing…' : 'Test Connection'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveState === 'ok' && <p className="settings-feedback settings-feedback--ok">✓ Credentials saved</p>}
|
{saveState === 'ok' && <p className="settings-feedback settings-feedback--ok">✓ Saved</p>}
|
||||||
{saveState === 'error' && <p className="settings-feedback settings-feedback--error">✗ Failed to save credentials</p>}
|
{saveState === 'error' && <p className="settings-feedback settings-feedback--error">✗ Failed to save</p>}
|
||||||
{testState === 'ok' && <p className="settings-feedback settings-feedback--ok">✓ {testMsg}</p>}
|
{testState === 'ok' && <p className="settings-feedback settings-feedback--ok">✓ {testMsg}</p>}
|
||||||
{testState === 'error' && <p className="settings-feedback settings-feedback--error">✗ {testMsg}</p>}
|
{testState === 'error' && <p className="settings-feedback settings-feedback--error">✗ {testMsg}</p>}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+106
-13
@@ -43,6 +43,68 @@ def _spotify_write(data: dict):
|
|||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bandcamp cookie auth ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _apply_cookies(session, cookie_str: str):
|
||||||
|
for part in cookie_str.split(';'):
|
||||||
|
part = part.strip()
|
||||||
|
if '=' in part:
|
||||||
|
name, _, value = part.partition('=')
|
||||||
|
session.cookies.set(name.strip(), value.strip(), domain='.bandcamp.com')
|
||||||
|
|
||||||
|
|
||||||
|
def _bc_verify_cookies(cookie_str: str) -> dict:
|
||||||
|
session = _requests.Session()
|
||||||
|
session.headers["User-Agent"] = (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
_apply_cookies(session, cookie_str)
|
||||||
|
|
||||||
|
# Try js_account_details cookie first (avoids an HTTP round-trip)
|
||||||
|
fan_id = None
|
||||||
|
username = ''
|
||||||
|
js_raw = session.cookies.get('js_account_details', '')
|
||||||
|
if js_raw:
|
||||||
|
try:
|
||||||
|
data = json.loads(unquote(js_raw))
|
||||||
|
fan_id = data.get('fan_id') or data.get('id')
|
||||||
|
username = data.get('username', '')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to fetching the homepage
|
||||||
|
if not fan_id:
|
||||||
|
try:
|
||||||
|
r = session.get("https://bandcamp.com/", timeout=15)
|
||||||
|
m = re.search(r'"fan_id"\s*:\s*(\d+)', r.text)
|
||||||
|
if m:
|
||||||
|
fan_id = int(m.group(1))
|
||||||
|
if not username:
|
||||||
|
m2 = re.search(r'"username"\s*:\s*"([^"]+)"', r.text)
|
||||||
|
if m2:
|
||||||
|
username = m2.group(1)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": f"Could not reach bandcamp.com: {exc}"}
|
||||||
|
|
||||||
|
if not fan_id:
|
||||||
|
return {"ok": False, "error": "Cookies invalid or expired — no fan account found"}
|
||||||
|
|
||||||
|
# Verify with a lightweight API call
|
||||||
|
try:
|
||||||
|
r = session.post(
|
||||||
|
"https://bandcamp.com/api/fan/2/collection_summary",
|
||||||
|
json={"fan_id": fan_id},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if not r.ok:
|
||||||
|
return {"ok": False, "error": "Cookies rejected by Bandcamp (may have expired)"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
return {"ok": True, "username": username}
|
||||||
|
|
||||||
|
|
||||||
# ── Bandcamp login ────────────────────────────────────────────────────────────
|
# ── Bandcamp login ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _bc_login(email: str, password: str) -> dict:
|
def _bc_login(email: str, password: str) -> dict:
|
||||||
@@ -52,33 +114,41 @@ def _bc_login(email: str, password: str) -> dict:
|
|||||||
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 1 — obtain CSRF token
|
# Step 1 — obtain CSRF token from meta tag
|
||||||
try:
|
try:
|
||||||
r = session.get("https://bandcamp.com/login", timeout=15)
|
r = session.get("https://bandcamp.com/login", timeout=15)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"ok": False, "error": f"Could not reach bandcamp.com: {exc}"}
|
return {"ok": False, "error": f"Could not reach bandcamp.com: {exc}"}
|
||||||
|
|
||||||
csrf = unquote(r.cookies.get("csrf_token", ""))
|
m = re.search(r'<meta\s+name=["\']csrf-token["\']\s+content=["\']([^"\']+)["\']', r.text, re.IGNORECASE)
|
||||||
if not csrf:
|
if not m:
|
||||||
m = re.search(r'"csrf_token"\s*:\s*"([^"]+)"', r.text)
|
m = re.search(r'<meta\s+content=["\']([^"\']+)["\']\s+name=["\']csrf-token["\']', r.text, re.IGNORECASE)
|
||||||
if m:
|
if not m:
|
||||||
csrf = unquote(m.group(1))
|
|
||||||
if not csrf:
|
|
||||||
return {"ok": False, "error": "Could not obtain CSRF token from Bandcamp"}
|
return {"ok": False, "error": "Could not obtain CSRF token from Bandcamp"}
|
||||||
|
csrf = m.group(1)
|
||||||
|
|
||||||
# Step 2 — POST login
|
# Step 2 — POST login using field names from Bandcamp's login JS
|
||||||
try:
|
try:
|
||||||
r = session.post(
|
r = session.post(
|
||||||
"https://bandcamp.com/login_cb",
|
"https://bandcamp.com/login_cb",
|
||||||
data={"username": email, "password": password, "csrf_token": csrf},
|
data={
|
||||||
|
"user.name": email,
|
||||||
|
"login.password": password,
|
||||||
|
"login.twofactor": "",
|
||||||
|
"login.twofactor_remember": "",
|
||||||
|
"login.from": "",
|
||||||
|
"to_band_path": "",
|
||||||
|
},
|
||||||
headers={
|
headers={
|
||||||
"X-CSRFToken": csrf,
|
"X-CSRF-TOKEN": csrf,
|
||||||
"Referer": "https://bandcamp.com/login",
|
"Referer": "https://bandcamp.com/login",
|
||||||
"Accept": "application/json, text/javascript, */*",
|
"Accept": "application/json, text/javascript, */*",
|
||||||
"Origin": "https://bandcamp.com",
|
"Origin": "https://bandcamp.com",
|
||||||
},
|
},
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
|
if not r.text:
|
||||||
|
return {"ok": False, "error": "Empty response from Bandcamp"}
|
||||||
payload = r.json()
|
payload = r.json()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"ok": False, "error": str(exc)}
|
return {"ok": False, "error": str(exc)}
|
||||||
@@ -87,7 +157,16 @@ def _bc_login(email: str, password: str) -> dict:
|
|||||||
username = (payload.get("account") or {}).get("username", "")
|
username = (payload.get("account") or {}).get("username", "")
|
||||||
return {"ok": True, "username": username}
|
return {"ok": True, "username": username}
|
||||||
|
|
||||||
err = payload.get("error") or payload.get("error_message") or "Login failed"
|
# Extract error from various response shapes
|
||||||
|
err = payload.get("error") or payload.get("error_message")
|
||||||
|
if not err and payload.get("errors"):
|
||||||
|
errors = payload["errors"]
|
||||||
|
if isinstance(errors, list):
|
||||||
|
parts = [f"{e.get('field','')}: {e.get('reason','')}" for e in errors if isinstance(e, dict)]
|
||||||
|
err = "; ".join(parts) if parts else str(errors)
|
||||||
|
else:
|
||||||
|
err = str(errors)
|
||||||
|
err = err or "Login failed"
|
||||||
return {"ok": False, "error": err}
|
return {"ok": False, "error": err}
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +192,11 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path in ("/credentials", "/credentials/"):
|
if self.path in ("/credentials", "/credentials/"):
|
||||||
cfg = _read()
|
cfg = _read()
|
||||||
self._json(200, {"email": cfg.get("email", ""), "password_set": bool(cfg.get("password"))})
|
self._json(200, {
|
||||||
|
"email": cfg.get("email", ""),
|
||||||
|
"password_set": bool(cfg.get("password")),
|
||||||
|
"cookies_set": bool(cfg.get("cookies")),
|
||||||
|
})
|
||||||
elif self.path in ("/spotify/credentials", "/spotify/credentials/"):
|
elif self.path in ("/spotify/credentials", "/spotify/credentials/"):
|
||||||
cfg = _spotify_read()
|
cfg = _spotify_read()
|
||||||
self._json(200, {
|
self._json(200, {
|
||||||
@@ -139,15 +222,25 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
cfg["email"] = body["email"].strip()
|
cfg["email"] = body["email"].strip()
|
||||||
if "password" in body and body["password"]:
|
if "password" in body and body["password"]:
|
||||||
cfg["password"] = body["password"]
|
cfg["password"] = body["password"]
|
||||||
|
if "cookies" in body:
|
||||||
|
if body["cookies"]:
|
||||||
|
cfg["cookies"] = body["cookies"].strip()
|
||||||
|
else:
|
||||||
|
cfg.pop("cookies", None)
|
||||||
_write(cfg)
|
_write(cfg)
|
||||||
self._json(200, {"ok": True})
|
self._json(200, {"ok": True})
|
||||||
|
|
||||||
elif self.path in ("/test-login", "/test-login/"):
|
elif self.path in ("/test-login", "/test-login/"):
|
||||||
cfg = _read()
|
cfg = _read()
|
||||||
|
cookies = cfg.get("cookies", "")
|
||||||
|
if cookies:
|
||||||
|
result = _bc_verify_cookies(cookies)
|
||||||
|
self._json(200, result)
|
||||||
|
return
|
||||||
email = cfg.get("email", "")
|
email = cfg.get("email", "")
|
||||||
password = cfg.get("password", "")
|
password = cfg.get("password", "")
|
||||||
if not email or not password:
|
if not email or not password:
|
||||||
self._json(400, {"ok": False, "error": "No credentials saved — save them first"})
|
self._json(400, {"ok": False, "error": "No credentials saved — save cookies or email/password first"})
|
||||||
return
|
return
|
||||||
result = _bc_login(email, password)
|
result = _bc_login(email, password)
|
||||||
self._json(200, result)
|
self._json(200, result)
|
||||||
|
|||||||
+87
-31
@@ -45,22 +45,21 @@ SESSION.headers["User-Agent"] = (
|
|||||||
|
|
||||||
# ── Credentials ───────────────────────────────────────────────────────────────
|
# ── Credentials ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def load_credentials() -> tuple[str, str]:
|
def load_credentials() -> tuple[str, str, str]:
|
||||||
|
"""Returns (email, password, cookies). Cookies take priority when present."""
|
||||||
email = os.environ.get("BANDCAMP_EMAIL", "")
|
email = os.environ.get("BANDCAMP_EMAIL", "")
|
||||||
password = os.environ.get("BANDCAMP_PASSWORD", "")
|
password = os.environ.get("BANDCAMP_PASSWORD", "")
|
||||||
|
cookies = ""
|
||||||
|
|
||||||
if email and password:
|
if email and password:
|
||||||
return email, password
|
return email, password, cookies
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = requests.get("http://localhost:8091/credentials", timeout=3)
|
r = requests.get("http://localhost:8091/credentials", timeout=3)
|
||||||
if r.ok:
|
if r.ok:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
api_email = data.get("email", "")
|
if data.get("email"):
|
||||||
# password_set=True but we can't retrieve the plaintext from GET;
|
email = data["email"]
|
||||||
# the service exposes /test-login but not the raw password.
|
|
||||||
# Fall through to the JSON file which stores plaintext.
|
|
||||||
if api_email:
|
|
||||||
email = api_email
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -70,19 +69,20 @@ def load_credentials() -> tuple[str, str]:
|
|||||||
cfg = json.load(f)
|
cfg = json.load(f)
|
||||||
email = cfg.get("email", email)
|
email = cfg.get("email", email)
|
||||||
password = cfg.get("password", password)
|
password = cfg.get("password", password)
|
||||||
|
cookies = cfg.get("cookies", cookies)
|
||||||
|
|
||||||
# Also try the Docker volume path (running on host alongside Docker)
|
|
||||||
docker_cfg = Path("/var/lib/docker/volumes/audioserver_bandcamp_config/_data/bandcamp.json")
|
docker_cfg = Path("/var/lib/docker/volumes/audioserver_bandcamp_config/_data/bandcamp.json")
|
||||||
if not password and docker_cfg.exists():
|
if not password and not cookies and docker_cfg.exists():
|
||||||
try:
|
try:
|
||||||
with open(docker_cfg) as f:
|
with open(docker_cfg) as f:
|
||||||
cfg = json.load(f)
|
cfg = json.load(f)
|
||||||
email = cfg.get("email", email)
|
email = cfg.get("email", email)
|
||||||
password = cfg.get("password", password)
|
password = cfg.get("password", password)
|
||||||
|
cookies = cfg.get("cookies", cookies)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return email, password
|
return email, password, cookies
|
||||||
|
|
||||||
|
|
||||||
# ── Bandcamp auth ─────────────────────────────────────────────────────────────
|
# ── Bandcamp auth ─────────────────────────────────────────────────────────────
|
||||||
@@ -90,20 +90,26 @@ def load_credentials() -> tuple[str, str]:
|
|||||||
def bc_login(email: str, password: str) -> dict:
|
def bc_login(email: str, password: str) -> dict:
|
||||||
"""Login to Bandcamp. Returns account dict with fan_id."""
|
"""Login to Bandcamp. Returns account dict with fan_id."""
|
||||||
r = SESSION.get("https://bandcamp.com/login", timeout=15)
|
r = SESSION.get("https://bandcamp.com/login", timeout=15)
|
||||||
csrf = unquote(r.cookies.get("csrf_token", ""))
|
m = re.search(r'<meta\s+name=["\']csrf-token["\']\s+content=["\']([^"\']+)["\']', r.text, re.IGNORECASE)
|
||||||
if not csrf:
|
if not m:
|
||||||
m = re.search(r'"csrf_token"\s*:\s*"([^"]+)"', r.text)
|
m = re.search(r'<meta\s+content=["\']([^"\']+)["\']\s+name=["\']csrf-token["\']', r.text, re.IGNORECASE)
|
||||||
if m:
|
if not m:
|
||||||
csrf = unquote(m.group(1))
|
|
||||||
if not csrf:
|
|
||||||
raise RuntimeError("Could not get CSRF token from Bandcamp login page")
|
raise RuntimeError("Could not get CSRF token from Bandcamp login page")
|
||||||
|
csrf = m.group(1)
|
||||||
|
|
||||||
time.sleep(REQUEST_DELAY)
|
time.sleep(REQUEST_DELAY)
|
||||||
r = SESSION.post(
|
r = SESSION.post(
|
||||||
"https://bandcamp.com/login_cb",
|
"https://bandcamp.com/login_cb",
|
||||||
data={"username": email, "password": password, "csrf_token": csrf},
|
data={
|
||||||
|
"user.name": email,
|
||||||
|
"login.password": password,
|
||||||
|
"login.twofactor": "",
|
||||||
|
"login.twofactor_remember": "",
|
||||||
|
"login.from": "",
|
||||||
|
"to_band_path": "",
|
||||||
|
},
|
||||||
headers={
|
headers={
|
||||||
"X-CSRFToken": csrf,
|
"X-CSRF-TOKEN": csrf,
|
||||||
"Referer": "https://bandcamp.com/login",
|
"Referer": "https://bandcamp.com/login",
|
||||||
"Accept": "application/json, text/javascript, */*",
|
"Accept": "application/json, text/javascript, */*",
|
||||||
"Origin": "https://bandcamp.com",
|
"Origin": "https://bandcamp.com",
|
||||||
@@ -112,12 +118,53 @@ def bc_login(email: str, password: str) -> dict:
|
|||||||
)
|
)
|
||||||
payload = r.json()
|
payload = r.json()
|
||||||
if payload.get("ok") != 1:
|
if payload.get("ok") != 1:
|
||||||
err = payload.get("error") or payload.get("error_message") or json.dumps(payload)
|
errors = payload.get("errors")
|
||||||
|
if errors and isinstance(errors, list):
|
||||||
|
err = "; ".join(f"{e.get('field','')}: {e.get('reason','')}" for e in errors if isinstance(e, dict))
|
||||||
|
else:
|
||||||
|
err = payload.get("error") or payload.get("error_message") or json.dumps(payload)
|
||||||
raise RuntimeError(f"Bandcamp login failed: {err}")
|
raise RuntimeError(f"Bandcamp login failed: {err}")
|
||||||
|
|
||||||
return payload.get("account", {})
|
return payload.get("account", {})
|
||||||
|
|
||||||
|
|
||||||
|
def apply_cookies(session: requests.Session, cookie_str: str):
|
||||||
|
for part in cookie_str.split(';'):
|
||||||
|
part = part.strip()
|
||||||
|
if '=' in part:
|
||||||
|
name, _, value = part.partition('=')
|
||||||
|
session.cookies.set(name.strip(), value.strip(), domain='.bandcamp.com')
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_info(session: requests.Session) -> tuple[int, str]:
|
||||||
|
"""Returns (fan_id, username) from session cookies or homepage."""
|
||||||
|
js_raw = session.cookies.get('js_account_details', '')
|
||||||
|
fan_id = None
|
||||||
|
username = ''
|
||||||
|
if js_raw:
|
||||||
|
try:
|
||||||
|
data = json.loads(unquote(js_raw))
|
||||||
|
fan_id = data.get('fan_id') or data.get('id')
|
||||||
|
username = data.get('username', '')
|
||||||
|
if fan_id:
|
||||||
|
return int(fan_id), username
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(REQUEST_DELAY)
|
||||||
|
r = session.get("https://bandcamp.com/", timeout=15)
|
||||||
|
m = re.search(r'"fan_id"\s*:\s*(\d+)', r.text)
|
||||||
|
if m:
|
||||||
|
fan_id = int(m.group(1))
|
||||||
|
if not username:
|
||||||
|
m2 = re.search(r'"username"\s*:\s*"([^"]+)"', r.text)
|
||||||
|
if m2:
|
||||||
|
username = m2.group(1)
|
||||||
|
if not fan_id:
|
||||||
|
raise RuntimeError("Could not determine fan_id from session cookies — they may have expired")
|
||||||
|
return fan_id, username
|
||||||
|
|
||||||
|
|
||||||
def get_fan_id(account: dict) -> int:
|
def get_fan_id(account: dict) -> int:
|
||||||
fan_id = account.get("fan_id") or account.get("id")
|
fan_id = account.get("fan_id") or account.get("id")
|
||||||
if fan_id:
|
if fan_id:
|
||||||
@@ -317,18 +364,27 @@ def main():
|
|||||||
|
|
||||||
music_dir = args.music_dir
|
music_dir = args.music_dir
|
||||||
|
|
||||||
# Load credentials
|
email, password, cookies = load_credentials()
|
||||||
email, password = load_credentials()
|
|
||||||
if not email or not password:
|
|
||||||
print("✗ No credentials found. Save them in the Settings → Bandcamp section, or set")
|
|
||||||
print(" BANDCAMP_EMAIL / BANDCAMP_PASSWORD environment variables.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"Logging in as {email}…")
|
if cookies:
|
||||||
account = bc_login(email, password)
|
apply_cookies(SESSION, cookies)
|
||||||
fan_id = get_fan_id(account)
|
print("Using saved session cookies…")
|
||||||
username = account.get("username", str(fan_id))
|
try:
|
||||||
print(f"✓ Logged in — {username} (fan_id={fan_id})")
|
fan_id, username = get_session_info(SESSION)
|
||||||
|
print(f"✓ Session active — {username or 'unknown'} (fan_id={fan_id})")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"✗ {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
elif email and password:
|
||||||
|
print(f"Logging in as {email}…")
|
||||||
|
account = bc_login(email, password)
|
||||||
|
fan_id = get_fan_id(account)
|
||||||
|
username = account.get("username", str(fan_id))
|
||||||
|
print(f"✓ Logged in — {username} (fan_id={fan_id})")
|
||||||
|
else:
|
||||||
|
print("✗ No credentials found. Save session cookies or email/password in Settings → Bandcamp,")
|
||||||
|
print(" or set BANDCAMP_EMAIL / BANDCAMP_PASSWORD environment variables.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
print("Fetching collection…")
|
print("Fetching collection…")
|
||||||
items = fetch_collection(fan_id)
|
items = fetch_collection(fan_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user