diff --git a/audiocontrol/src/components/SettingsPanel.jsx b/audiocontrol/src/components/SettingsPanel.jsx
index 2d5c8dd..cd724d0 100644
--- a/audiocontrol/src/components/SettingsPanel.jsx
+++ b/audiocontrol/src/components/SettingsPanel.jsx
@@ -251,13 +251,15 @@ function SpotifySection() {
// ── Bandcamp credentials ──────────────────────────────────────────────────────
function BandcampSection() {
- const [email, setEmail] = useState('');
- const [password, setPassword] = useState('');
- const [pwdSet, setPwdSet] = useState(false);
- const [loaded, setLoaded] = useState(false);
- const [saveState, setSaveState] = useState('idle');
- const [testState, setTestState] = useState('idle');
- const [testMsg, setTestMsg] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [cookies, setCookies] = useState('');
+ const [pwdSet, setPwdSet] = useState(false);
+ const [cookiesSet, setCookiesSet] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [saveState, setSaveState] = useState('idle');
+ const [testState, setTestState] = useState('idle');
+ const [testMsg, setTestMsg] = useState('');
useEffect(() => {
fetch('/api/bandcamp/credentials')
@@ -265,6 +267,7 @@ function BandcampSection() {
.then(data => {
setEmail(data.email || '');
setPwdSet(!!data.password_set);
+ setCookiesSet(!!data.cookies_set);
setLoaded(true);
})
.catch(() => setLoaded(true));
@@ -275,6 +278,7 @@ function BandcampSection() {
try {
const body = { email };
if (password) body.password = password;
+ if (cookies) body.cookies = cookies;
const r = await fetch('/api/bandcamp/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -282,16 +286,26 @@ function BandcampSection() {
});
if (!r.ok) throw new Error();
setSaveState('ok');
- if (password) setPwdSet(true);
- setPassword('');
+ if (password) { setPwdSet(true); setPassword(''); }
+ if (cookies) { setCookiesSet(true); setCookies(''); }
} catch {
setSaveState('error');
}
setTimeout(() => setSaveState('idle'), 4000);
};
- const testLogin = async () => {
- if (email || password) await save();
+ const clearCookies = async () => {
+ 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');
setTestMsg('');
try {
@@ -299,8 +313,8 @@ function BandcampSection() {
const data = await r.json();
setTestState(data.ok ? 'ok' : 'error');
setTestMsg(data.ok
- ? `Logged in${data.username ? ` as ${data.username}` : ''}`
- : (data.error || 'Login failed'));
+ ? `Connected${data.username ? ` as ${data.username}` : ''}`
+ : (data.error || 'Connection failed'));
} catch {
setTestState('error');
setTestMsg('Could not reach bandcamp-api service');
@@ -310,10 +324,42 @@ function BandcampSection() {
return (
<>
+
+ Session cookies bypass Bandcamp's login captcha and are used first when saved.
+ Log in at{' '}
+
+ bandcamp.com
+
+ {' '}in your browser, open DevTools (F12) → Network tab → click any bandcamp.com
+ request → Request Headers → copy the
Cookie value and paste it below.
+
+
+
+
+
Session Cookies
+
+ {!loaded ? '…' : cookiesSet
+ ? Saved — clear
+ : 'Not saved'}
+
+
+
+
+
+ Email and password are used as fallback when no cookies are saved.
+
+
Email
-
Bandcamp account email
Password
- {loaded && !pwdSet ? 'Not saved yet' : loaded ? 'Saved (leave blank to keep)' : '…'}
+ {loaded && !pwdSet ? 'Not saved' : loaded ? 'Saved (leave blank to keep)' : '…'}
{saveState === 'saving' ? 'Saving…' : 'Save'}
-
- {testState === 'testing' ? 'Testing…' : 'Test Login'}
+
+ {testState === 'testing' ? 'Testing…' : 'Test Connection'}
- {saveState === 'ok' && ✓ Credentials saved
}
- {saveState === 'error' && ✗ Failed to save credentials
}
+ {saveState === 'ok' && ✓ Saved
}
+ {saveState === 'error' && ✗ Failed to save
}
{testState === 'ok' && ✓ {testMsg}
}
{testState === 'error' && ✗ {testMsg}
}
>
diff --git a/bandcamp-api/app.py b/bandcamp-api/app.py
index 04574ee..3fe3c78 100644
--- a/bandcamp-api/app.py
+++ b/bandcamp-api/app.py
@@ -43,6 +43,68 @@ def _spotify_write(data: dict):
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 ────────────────────────────────────────────────────────────
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"
)
- # Step 1 — obtain CSRF token
+ # Step 1 — obtain CSRF token from meta tag
try:
r = session.get("https://bandcamp.com/login", timeout=15)
except Exception as exc:
return {"ok": False, "error": f"Could not reach bandcamp.com: {exc}"}
- csrf = unquote(r.cookies.get("csrf_token", ""))
- if not csrf:
- m = re.search(r'"csrf_token"\s*:\s*"([^"]+)"', r.text)
- if m:
- csrf = unquote(m.group(1))
- if not csrf:
+ m = re.search(r' dict:
username = (payload.get("account") or {}).get("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}
@@ -113,7 +192,11 @@ class _Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path in ("/credentials", "/credentials/"):
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/"):
cfg = _spotify_read()
self._json(200, {
@@ -139,15 +222,25 @@ class _Handler(BaseHTTPRequestHandler):
cfg["email"] = body["email"].strip()
if "password" in body and 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)
self._json(200, {"ok": True})
elif self.path in ("/test-login", "/test-login/"):
cfg = _read()
+ cookies = cfg.get("cookies", "")
+ if cookies:
+ result = _bc_verify_cookies(cookies)
+ self._json(200, result)
+ return
email = cfg.get("email", "")
password = cfg.get("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
result = _bc_login(email, password)
self._json(200, result)
diff --git a/scripts/bandcamp-sync.py b/scripts/bandcamp-sync.py
index 580d961..1367897 100755
--- a/scripts/bandcamp-sync.py
+++ b/scripts/bandcamp-sync.py
@@ -45,22 +45,21 @@ SESSION.headers["User-Agent"] = (
# ── 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", "")
password = os.environ.get("BANDCAMP_PASSWORD", "")
+ cookies = ""
+
if email and password:
- return email, password
+ return email, password, cookies
try:
r = requests.get("http://localhost:8091/credentials", timeout=3)
if r.ok:
data = r.json()
- api_email = data.get("email", "")
- # password_set=True but we can't retrieve the plaintext from GET;
- # 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
+ if data.get("email"):
+ email = data["email"]
except Exception:
pass
@@ -70,19 +69,20 @@ def load_credentials() -> tuple[str, str]:
cfg = json.load(f)
email = cfg.get("email", email)
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")
- if not password and docker_cfg.exists():
+ if not password and not cookies and docker_cfg.exists():
try:
with open(docker_cfg) as f:
cfg = json.load(f)
email = cfg.get("email", email)
password = cfg.get("password", password)
+ cookies = cfg.get("cookies", cookies)
except Exception:
pass
- return email, password
+ return email, password, cookies
# ── Bandcamp auth ─────────────────────────────────────────────────────────────
@@ -90,20 +90,26 @@ def load_credentials() -> tuple[str, str]:
def bc_login(email: str, password: str) -> dict:
"""Login to Bandcamp. Returns account dict with fan_id."""
r = SESSION.get("https://bandcamp.com/login", timeout=15)
- csrf = unquote(r.cookies.get("csrf_token", ""))
- if not csrf:
- m = re.search(r'"csrf_token"\s*:\s*"([^"]+)"', r.text)
- if m:
- csrf = unquote(m.group(1))
- if not csrf:
+ m = re.search(r' dict:
)
payload = r.json()
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}")
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:
fan_id = account.get("fan_id") or account.get("id")
if fan_id:
@@ -317,18 +364,27 @@ def main():
music_dir = args.music_dir
- # 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)
+ email, password, cookies = load_credentials()
- 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})")
+ if cookies:
+ apply_cookies(SESSION, cookies)
+ print("Using saved session cookies…")
+ try:
+ 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…")
items = fetch_collection(fan_id)