Spotify integration added

This commit is contained in:
2026-05-17 13:35:34 +02:00
parent 9a9275dae5
commit 4d53efc9e4
16 changed files with 1890 additions and 82 deletions
+2 -2
View File
@@ -22,8 +22,8 @@ TZ=Europe/Vienna
HIFIBERRY_CARD=sndrpihifiberry HIFIBERRY_CARD=sndrpihifiberry
# Spotify credentials for mopidy-spotify # Spotify credentials for mopidy-spotify
SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_ID=b168b3fa4c124a5e91cfeb6c2af23fc0
SPOTIFY_CLIENT_SECRET= SPOTIFY_CLIENT_SECRET=e357609b4aa74752aca03a9ba58c3978
# IP/hostname of this Pi as seen by LAN clients (Snapserver HTTP host, Mopidy/Iris) # IP/hostname of this Pi as seen by LAN clients (Snapserver HTTP host, Mopidy/Iris)
SNAPSERVER_HOST=192.168.178.100 SNAPSERVER_HOST=192.168.178.100
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-D7ESsBgL.js"></script> <script type="module" crossorigin src="/assets/index-vRxHNr4-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-SAHpcFVS.css"> <link rel="stylesheet" crossorigin href="/assets/index-SAHpcFVS.css">
</head> </head>
<body> <body>
+265 -7
View File
@@ -595,14 +595,271 @@ function MediaBrowser({ onAdd }) {
); );
} }
function BrowserSection({ onAdd }) { function SourceBrowser({ onAdd }) {
const [open, setOpen] = useState(true); const [source, setSource] = useState('local');
return ( return (
<div> <div>
<button className="section-toggle" onClick={() => setOpen(o => !o)}> <div className="browser-mode-row">
BROWSE LIBRARY {open ? '▲' : '▼'} <button
</button> className={`browser-mode-btn ${source === 'local' ? 'browser-mode-btn--active' : ''}`}
{open && <MediaBrowser onAdd={onAdd} />} onClick={() => setSource('local')}
>
Local Library
</button>
<button
className={`browser-mode-btn ${source === 'spotify' ? 'browser-mode-btn--active' : ''}`}
onClick={() => setSource('spotify')}
>
Spotify
</button>
</div>
{source === 'local' ? <MediaBrowser onAdd={onAdd} /> : <SpotifyBrowser onAdd={onAdd} />}
</div>
);
}
function SpotifyBrowser({ onAdd }) {
const [stack, setStack] = useState([{ name: 'Spotify', uri: 'spotify:directory' }]);
const [items, setItems] = useState(null);
const [covers, setCovers] = useState({});
const [loading, setLoading] = useState(false);
const [albumDetail, setAlbumDetail] = useState(null);
const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState('');
const [searching, setSearching] = useState(false);
const [searchAlbums, setSearchAlbums] = useState([]);
const [searchAlbumCovers, setSearchAlbumCovers] = useState({});
const [searchTracks, setSearchTracks] = useState([]);
const [searchTrackCovers, setSearchTrackCovers] = useState({});
const currentUri = stack[stack.length - 1].uri;
useEffect(() => {
if (searching || searchOpen) return;
let cancelled = false;
setLoading(true);
setItems(null);
setCovers({});
mopidy.browse(currentUri)
.then(r => { if (!cancelled) setItems(r ?? []); })
.catch(() => { if (!cancelled) setItems([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [currentUri, searching, searchOpen]);
useEffect(() => {
if (searching || searchOpen || !items?.length) return;
const coverable = items.filter(i => i.type === 'album' || i.type === 'playlist');
if (!coverable.length) return;
let cancelled = false;
mopidy.getImages(coverable.map(i => i.uri))
.then(r => { if (!cancelled) setCovers(r ?? {}); })
.catch(() => { if (!cancelled) setCovers({}); });
return () => { cancelled = true; };
}, [items, searching, searchOpen]);
useEffect(() => {
if (!searching || !searchAlbums.length) { setSearchAlbumCovers({}); return undefined; }
let cancelled = false;
mopidy.getImages(searchAlbums.map(a => a.uri))
.then(r => { if (!cancelled) setSearchAlbumCovers(r ?? {}); })
.catch(() => { if (!cancelled) setSearchAlbumCovers({}); });
return () => { cancelled = true; };
}, [searchAlbums, searching]);
useEffect(() => {
if (!searching || !searchTracks.length) { setSearchTrackCovers({}); return undefined; }
let cancelled = false;
const uris = [...new Set(searchTracks.flatMap(t => [t.uri, t.album?.uri].filter(Boolean)))];
mopidy.getImages(uris)
.then(r => { if (!cancelled) setSearchTrackCovers(r ?? {}); })
.catch(() => { if (!cancelled) setSearchTrackCovers({}); });
return () => { cancelled = true; };
}, [searchTracks, searching]);
const navigate = (item) => {
if (item.type === 'album' || item.type === 'playlist') {
setAlbumDetail({ name: item.name, uri: item.uri });
} else {
setAlbumDetail(null);
setStack(s => [...s, { name: item.name, uri: item.uri }]);
}
};
const back = () => {
if (albumDetail) {
setAlbumDetail(null);
} else if (stack.length > 1) {
setStack(s => s.slice(0, -1));
}
};
const doSearch = async () => {
if (!query.trim()) { clearSearch(); return; }
setSearching(true);
setLoading(true);
setAlbumDetail(null);
try {
const res = await mopidy.search(query, ['spotify:']);
setSearchAlbums((res ?? []).flatMap(r => r.albums ?? []).slice(0, 48));
setSearchTracks((res ?? []).flatMap(r => r.tracks ?? []).slice(0, 80));
} catch {
setSearching(false);
setSearchAlbums([]);
setSearchTracks([]);
} finally {
setLoading(false);
}
};
const clearSearch = () => {
setSearching(false);
setSearchOpen(false);
setQuery('');
setSearchAlbums([]);
setSearchTracks([]);
setAlbumDetail(null);
};
const coverables = items?.filter(i => i.type === 'album' || i.type === 'playlist') ?? [];
const dirs = items?.filter(i => i.type === 'directory') ?? [];
const trackItems = sortAlbumTracks(items?.filter(i => i.type === 'track') ?? []);
const showCoverGrid = !searching && !searchOpen && !loading && coverables.length > 0;
return (
<div className="browser">
<div className="browser-mode-row">
<button
className={`browser-mode-btn browser-mode-btn--search ${searchOpen || searching ? 'browser-mode-btn--active' : ''}`}
onClick={() => { if (searchOpen || searching) clearSearch(); else setSearchOpen(true); }}
>
Search
</button>
</div>
{(searchOpen || searching) && (
<div className="browser-search-row">
<input
className="browser-input"
type="search"
placeholder="Search Spotify…"
value={query}
autoFocus
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') doSearch(); if (e.key === 'Escape') clearSearch(); }}
/>
{searching
? <button className="browser-icon-btn" onClick={clearSearch}></button>
: <button className="browser-icon-btn" onClick={doSearch}></button>
}
</div>
)}
{!searchOpen && !searching && (
<div className="browser-nav">
<IconButton
className="browser-back-btn"
title="Back"
onClick={back}
disabled={!albumDetail && stack.length <= 1}
></IconButton>
<div className="browser-crumbs mono">
{stack.map((item, i) => (
<span key={`${item.uri}-${i}`} className="browser-crumb">
{i > 0 && <span className="browser-crumb-sep">/</span>}
{item.name}
</span>
))}
{albumDetail && (
<span className="browser-crumb">
<span className="browser-crumb-sep">/</span>
{albumDetail.name}
</span>
)}
</div>
</div>
)}
<div className="browser-list">
{albumDetail && (
<AlbumDetail album={albumDetail} onBack={back} onAdd={onAdd} />
)}
{!albumDetail && loading && <div className="browser-msg">Loading</div>}
{!albumDetail && showCoverGrid && (
<CoverGrid albums={coverables} covers={covers} onOpen={navigate} />
)}
{!albumDetail && !showCoverGrid && !searching && !searchOpen && !loading && dirs.map(item => (
<div key={item.uri} className="browser-row browser-row--dir" onClick={() => navigate(item)}>
<span className="browser-type mono">DIR</span>
<span className="browser-name">{item.name}</span>
<span className="browser-arrow"></span>
</div>
))}
{!albumDetail && !searching && !searchOpen && !loading && trackItems.map((track, i) => {
const artist = track.artists?.map(a => a.name).join(', ') ?? '';
return (
<div key={track.uri ?? i} className="browser-row">
<span className="browser-type mono">{i + 1}</span>
<div className="browser-name-col">
<span className="browser-name">{track.name}</span>
{artist && <span className="browser-sub">{artist}</span>}
</div>
<IconButton
className="browser-add-btn"
title="Add to queue"
onClick={e => { e.stopPropagation(); onAdd([track.uri], 'Track added to queue'); }}
>+</IconButton>
</div>
);
})}
{!albumDetail && searching && !loading && searchAlbums.length > 0 && (
<>
<div className="section-label" style={{ display: 'block', padding: '10px 0 6px' }}>Albums</div>
<CoverGrid albums={searchAlbums} covers={searchAlbumCovers} onOpen={navigate} />
</>
)}
{!albumDetail && searching && !loading && searchTracks.map((track, i) => {
const artist = track.artists?.map(a => a.name).join(', ') ?? '';
const coverUri = coverUriFor(searchTrackCovers, track.uri) || coverUriFor(searchTrackCovers, track.album?.uri);
return (
<div key={`${track.uri}-${i}`} className="browser-row browser-row--search">
<span className="search-thumb">
{coverUri ? <img src={coverUri} alt="" loading="lazy" /> : 'TRK'}
</span>
<div className="browser-name-col">
<span className="browser-name">{track.name}</span>
{artist && <span className="browser-sub">{artist}</span>}
{track.album?.name && <span className="browser-sub">{track.album.name}</span>}
</div>
<IconButton
className="browser-add-btn"
title="Add track to queue"
onClick={() => onAdd([track.uri], 'Track added to queue')}
>+</IconButton>
</div>
);
})}
{searchOpen && !searching && !query.trim() && (
<div className="browser-msg">Search for albums and tracks on Spotify.</div>
)}
{searching && !loading && searchAlbums.length === 0 && searchTracks.length === 0 && (
<div className="browser-msg">No results for "{query}".</div>
)}
{!albumDetail && !loading && !searching && !searchOpen && items?.length === 0 && (
<div className="browser-msg">
{currentUri === 'spotify:directory'
? 'Spotify not configured — add credentials in Settings → Spotify, then restart Mopidy.'
: 'Nothing here.'}
</div>
)}
</div>
</div> </div>
); );
} }
@@ -768,8 +1025,9 @@ export function MopidyPanel() {
{/* Media browser */} {/* Media browser */}
<section className="panel-section"> <section className="panel-section">
<BrowserSection onAdd={addTracksWithFeedback} /> <SourceBrowser onAdd={addTracksWithFeedback} />
</section> </section>
</div> </div>
); );
} }
@@ -151,6 +151,213 @@ function ConnectionsSection() {
); );
} }
// ── Spotify credentials ───────────────────────────────────────────────────────
function SpotifySection() {
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [secretSet, setSecretSet] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saveState, setSaveState] = useState('idle');
useEffect(() => {
fetch('/api/spotify/credentials')
.then(r => r.json())
.then(data => {
setClientId(data.client_id || '');
setSecretSet(!!data.client_secret_set);
setLoaded(true);
})
.catch(() => setLoaded(true));
}, []);
const save = async () => {
setSaveState('saving');
try {
const body = { client_id: clientId };
if (clientSecret) body.client_secret = clientSecret;
const r = await fetch('/api/spotify/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error();
setSaveState('ok');
if (clientSecret) { setSecretSet(true); setClientSecret(''); }
} catch {
setSaveState('error');
}
setTimeout(() => setSaveState('idle'), 4000);
};
return (
<>
<div className="settings-row-hint" style={{ marginBottom: 10 }}>
Visit{' '}
<a href="https://mopidy.com/ext/spotify/#authentication" target="_blank" rel="noreferrer"
style={{ color: 'var(--accent)' }}>
mopidy.com/ext/spotify/#authentication
</a>
, log in with your Spotify account, and paste the Client ID and Client Secret it gives you below.
Requires Spotify Premium.
</div>
<div className="settings-row">
<div className="settings-row-left">
<div className="settings-row-label">Client ID</div>
</div>
<input
className="settings-input"
value={clientId}
onChange={e => setClientId(e.target.value)}
spellCheck={false}
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
/>
</div>
<div className="settings-row">
<div className="settings-row-left">
<div className="settings-row-label">Client Secret</div>
<div className="settings-row-hint">
{loaded && !secretSet ? 'Not saved yet' : loaded ? 'Saved (leave blank to keep)' : '…'}
</div>
</div>
<input
className="settings-input"
type="password"
value={clientSecret}
onChange={e => setClientSecret(e.target.value)}
placeholder={secretSet ? '••••••••••••••••' : ''}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<button className="settings-action-btn" onClick={save} disabled={saveState === 'saving'}>
{saveState === 'saving' ? 'Saving…' : 'Save'}
</button>
</div>
{saveState === 'ok' && (
<>
<p className="settings-feedback settings-feedback--ok"> Credentials saved</p>
<p className="settings-feedback settings-feedback--warn"> Restart Mopidy to apply: <code>docker compose restart mopidy</code></p>
</>
)}
{saveState === 'error' && <p className="settings-feedback settings-feedback--error"> Failed to save credentials</p>}
</>
);
}
// ── 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('');
useEffect(() => {
fetch('/api/bandcamp/credentials')
.then(r => r.json())
.then(data => {
setEmail(data.email || '');
setPwdSet(!!data.password_set);
setLoaded(true);
})
.catch(() => setLoaded(true));
}, []);
const save = async () => {
setSaveState('saving');
try {
const body = { email };
if (password) body.password = password;
const r = await fetch('/api/bandcamp/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error();
setSaveState('ok');
if (password) setPwdSet(true);
setPassword('');
} catch {
setSaveState('error');
}
setTimeout(() => setSaveState('idle'), 4000);
};
const testLogin = async () => {
if (email || password) await save();
setTestState('testing');
setTestMsg('');
try {
const r = await fetch('/api/bandcamp/test-login', { method: 'POST' });
const data = await r.json();
setTestState(data.ok ? 'ok' : 'error');
setTestMsg(data.ok
? `Logged in${data.username ? ` as ${data.username}` : ''}`
: (data.error || 'Login failed'));
} catch {
setTestState('error');
setTestMsg('Could not reach bandcamp-api service');
}
setTimeout(() => setTestState('idle'), 8000);
};
return (
<>
<div className="settings-row">
<div className="settings-row-left">
<div className="settings-row-label">Email</div>
<div className="settings-row-hint">Bandcamp account email</div>
</div>
<input
className="settings-input"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
spellCheck={false}
placeholder="you@example.com"
/>
</div>
<div className="settings-row">
<div className="settings-row-left">
<div className="settings-row-label">Password</div>
<div className="settings-row-hint">
{loaded && !pwdSet ? 'Not saved yet' : loaded ? 'Saved (leave blank to keep)' : '…'}
</div>
</div>
<input
className="settings-input"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={pwdSet ? '••••••••' : ''}
/>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
<button className="settings-action-btn" onClick={save} disabled={saveState === 'saving'}>
{saveState === 'saving' ? 'Saving…' : 'Save'}
</button>
<button className="settings-action-btn" onClick={testLogin} disabled={testState === 'testing'}>
{testState === 'testing' ? 'Testing…' : 'Test Login'}
</button>
</div>
{saveState === 'ok' && <p className="settings-feedback settings-feedback--ok"> Credentials saved</p>}
{saveState === 'error' && <p className="settings-feedback settings-feedback--error"> Failed to save credentials</p>}
{testState === 'ok' && <p className="settings-feedback settings-feedback--ok"> {testMsg}</p>}
{testState === 'error' && <p className="settings-feedback settings-feedback--error"> {testMsg}</p>}
</>
);
}
// ── Main panel ──────────────────────────────────────────────────────────────── // ── Main panel ────────────────────────────────────────────────────────────────
export function SettingsPanel() { export function SettingsPanel() {
const { statuses, check } = useServiceHealth(); const { statuses, check } = useServiceHealth();
@@ -200,6 +407,16 @@ export function SettingsPanel() {
<ConnectionsSection /> <ConnectionsSection />
</div> </div>
<div className="panel-section">
<div className="section-label" style={{ marginBottom: 14 }}>SPOTIFY</div>
<SpotifySection />
</div>
<div className="panel-section">
<div className="section-label" style={{ marginBottom: 14 }}>BANDCAMP</div>
<BandcampSection />
</div>
<div className="panel-section"> <div className="panel-section">
<div className="section-label" style={{ marginBottom: 14 }}>MOPIDY</div> <div className="section-label" style={{ marginBottom: 14 }}>MOPIDY</div>
<div className="settings-row"> <div className="settings-row">
@@ -0,0 +1,104 @@
import { useSnapcast } from '../hooks/useSnapcast';
const SPOTIFY_STREAM_NAMES = ['spotify', 'librespot'];
function isSpotifyStream(stream) {
const name = (stream?.id ?? stream?.uri?.query?.name ?? '').toLowerCase();
return SPOTIFY_STREAM_NAMES.some(n => name.includes(n));
}
export function SpotifyPanel() {
const { streams, groups, clients, connected, error, setGroupStream } = useSnapcast();
const spotifyStream = streams.find(isSpotifyStream);
const isPlaying = spotifyStream?.status === 'playing';
const spotifyGroups = groups.filter(g => g.stream_id === spotifyStream?.id);
const otherGroups = groups.filter(g => g.stream_id !== spotifyStream?.id);
return (
<div className="panel">
<section className="panel-section">
<div className="section-header">
<span className="section-label">SPOTIFY</span>
<span className="section-value">
{connected
? <span className={`dot ${spotifyStream ? (isPlaying ? 'dot-green' : 'dot-dim') : 'dot-red'}`} />
: <span className="dot dot-red" />
}
</span>
</div>
{error && <div className="panel-error">{error}</div>}
{connected && !spotifyStream && (
<div className="panel-error">
No Spotify stream found. Configure librespot as a Snapcast source.
</div>
)}
{connected && spotifyStream && (
<div className="section-header" style={{ marginTop: 8 }}>
<span className="section-label">Stream</span>
<span className="section-value mono">{spotifyStream.id} · {spotifyStream.status}</span>
</div>
)}
</section>
{connected && spotifyStream && (
<section className="panel-section">
<div className="section-header">
<span className="section-label">ROUTING</span>
</div>
{groups.length === 0 && (
<div className="browser-msg">No Snapcast groups found.</div>
)}
{spotifyGroups.length > 0 && (
<div style={{ marginBottom: 8 }}>
<div className="section-label" style={{ fontSize: '10px', marginBottom: 6 }}>Playing Spotify</div>
{spotifyGroups.map(group => (
<div key={group.id} className="stream-row">
<span className="dot dot-green" />
<span className="stream-id mono">{group.name || group.id.slice(0, 8)}</span>
<span className="stream-status">
{group.clients?.filter(c => c.connected).length ?? 0} clients
</span>
</div>
))}
</div>
)}
{otherGroups.length > 0 && (
<div>
<div className="section-label" style={{ fontSize: '10px', marginBottom: 6 }}>Other groups</div>
{otherGroups.map(group => (
<div key={group.id} className="stream-row">
<span className="dot dot-dim" />
<span className="stream-id mono">{group.name || group.id.slice(0, 8)}</span>
<span className="stream-status mono">{group.stream_id}</span>
<button
className="recognize-btn"
style={{ marginLeft: 'auto' }}
onClick={() => setGroupStream(group.id, spotifyStream.id)}
>
Switch
</button>
</div>
))}
</div>
)}
</section>
)}
{connected && spotifyStream && clients.length === 0 && (
<div className="panel-empty">No Snapcast clients connected</div>
)}
{!connected && !error && (
<div className="browser-msg">Connecting to Snapcast</div>
)}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.12-slim
RUN pip install --no-cache-dir requests
WORKDIR /app
COPY app.py .
HEALTHCHECK --interval=20s --timeout=5s \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8091/health')" || exit 1
CMD ["python3", "app.py"]
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Bandcamp credential store + login-test micro-service."""
import json
import os
import re
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import unquote
import requests as _requests
CONFIG_FILE = os.environ.get("CONFIG_FILE", "/config/bandcamp.json")
SPOTIFY_CONFIG_FILE = os.environ.get("SPOTIFY_CONFIG_FILE", "/config/spotify.json")
# ── Config helpers ────────────────────────────────────────────────────────────
def _read():
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE) as f:
return json.load(f)
return {}
def _write(data: dict):
os.makedirs(os.path.dirname(CONFIG_FILE) or ".", exist_ok=True)
with open(CONFIG_FILE, "w") as f:
json.dump(data, f, indent=2)
# ── Spotify credential helpers ────────────────────────────────────────────────
def _spotify_read() -> dict:
if os.path.exists(SPOTIFY_CONFIG_FILE):
with open(SPOTIFY_CONFIG_FILE) as f:
return json.load(f)
return {}
def _spotify_write(data: dict):
os.makedirs(os.path.dirname(SPOTIFY_CONFIG_FILE) or ".", exist_ok=True)
with open(SPOTIFY_CONFIG_FILE, "w") as f:
json.dump(data, f, indent=2)
# ── Bandcamp login ────────────────────────────────────────────────────────────
def _bc_login(email: str, password: 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"
)
# Step 1 — obtain CSRF token
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:
return {"ok": False, "error": "Could not obtain CSRF token from Bandcamp"}
# Step 2 — POST login
try:
r = session.post(
"https://bandcamp.com/login_cb",
data={"username": email, "password": password, "csrf_token": csrf},
headers={
"X-CSRFToken": csrf,
"Referer": "https://bandcamp.com/login",
"Accept": "application/json, text/javascript, */*",
"Origin": "https://bandcamp.com",
},
timeout=15,
)
payload = r.json()
except Exception as exc:
return {"ok": False, "error": str(exc)}
if payload.get("ok") == 1 or payload.get("account"):
username = (payload.get("account") or {}).get("username", "")
return {"ok": True, "username": username}
err = payload.get("error") or payload.get("error_message") or "Login failed"
return {"ok": False, "error": err}
# ── HTTP handler ──────────────────────────────────────────────────────────────
class _Handler(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def _json(self, status: int, data: dict):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Allow", "GET, POST, OPTIONS")
self.end_headers()
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"))})
elif self.path in ("/spotify/credentials", "/spotify/credentials/"):
cfg = _spotify_read()
self._json(200, {
"client_id": cfg.get("client_id", ""),
"client_secret_set": bool(cfg.get("client_secret")),
})
elif self.path in ("/health", "/health/"):
self._json(200, {"ok": True})
else:
self._json(404, {"error": "not found"})
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
try:
body = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
self._json(400, {"error": "invalid JSON"})
return
if self.path in ("/credentials", "/credentials/"):
cfg = _read()
if "email" in body:
cfg["email"] = body["email"].strip()
if "password" in body and body["password"]:
cfg["password"] = body["password"]
_write(cfg)
self._json(200, {"ok": True})
elif self.path in ("/test-login", "/test-login/"):
cfg = _read()
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"})
return
result = _bc_login(email, password)
self._json(200, result)
elif self.path in ("/spotify/credentials", "/spotify/credentials/"):
cfg = _spotify_read()
if "client_id" in body:
cfg["client_id"] = body["client_id"].strip()
if "client_secret" in body and body["client_secret"]:
cfg["client_secret"] = body["client_secret"].strip()
_spotify_write(cfg)
self._json(200, {"ok": True})
else:
self._json(404, {"error": "not found"})
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8091))
server = HTTPServer(("0.0.0.0", port), _Handler)
print(f"bandcamp-api listening on :{port}", flush=True)
server.serve_forever()
+11
View File
@@ -45,6 +45,7 @@ services:
- ./mopidy/mopidy.conf:/config/mopidy.conf:ro - ./mopidy/mopidy.conf:/config/mopidy.conf:ro
- ${MUSIC_DIR:-/opt/audiocontrol/music}:/music:ro - ${MUSIC_DIR:-/opt/audiocontrol/music}:/music:ro
- mopidy_data:/var/lib/mopidy - mopidy_data:/var/lib/mopidy
- bandcamp_config:/config/shared:ro
- <<: *audio-volume - <<: *audio-volume
depends_on: depends_on:
- snapserver - snapserver
@@ -55,6 +56,14 @@ services:
- SNAPSERVER_HOST=${SNAPSERVER_HOST:-192.168.178.100} - SNAPSERVER_HOST=${SNAPSERVER_HOST:-192.168.178.100}
- BEETS_HOST=${BEETS_HOST:-192.168.178.100} - BEETS_HOST=${BEETS_HOST:-192.168.178.100}
# ── Bandcamp API (credential store + login test) ─────────────────────────────
bandcamp-api:
build: ./bandcamp-api
container_name: bandcamp-api
restart: unless-stopped
volumes:
- bandcamp_config:/config
# ── Frontend (React + Nginx proxy) ─────────────────────────────────────────── # ── Frontend (React + Nginx proxy) ───────────────────────────────────────────
frontend: frontend:
build: build:
@@ -77,6 +86,8 @@ services:
depends_on: depends_on:
- snapserver - snapserver
- mopidy - mopidy
- bandcamp-api
volumes: volumes:
mopidy_data: mopidy_data:
bandcamp_config:
+22
View File
@@ -72,6 +72,28 @@ server {
proxy_read_timeout 10s; proxy_read_timeout 10s;
} }
# ── Spotify credential store (served by bandcamp-api) ───────────────────
location /api/spotify/ {
set $bandcamp_api http://bandcamp-api:8091;
rewrite ^/api/spotify/(.*)$ /spotify/$1 break;
proxy_pass $bandcamp_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 15s;
proxy_connect_timeout 10s;
}
# ── Bandcamp API (credential store + login test) ─────────────────────────
location /api/bandcamp/ {
set $bandcamp_api http://bandcamp-api:8091;
rewrite ^/api/bandcamp/(.*)$ /$1 break;
proxy_pass $bandcamp_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
proxy_connect_timeout 10s;
}
# ── Snapcast now playing recognition ───────────────────────────────────── # ── Snapcast now playing recognition ─────────────────────────────────────
# host.docker.internal lives in /etc/hosts (extra_hosts), not in Docker's # host.docker.internal lives in /etc/hosts (extra_hosts), not in Docker's
# embedded DNS (127.0.0.11), so we must NOT use a set/$var here — a static # embedded DNS (127.0.0.11), so we must NOT use a set/$var here — a static
+39 -24
View File
@@ -1,46 +1,61 @@
FROM debian:bookworm-slim FROM python:3.13-slim
# Install Mopidy from the official repo + GStreamer + plugins # ── System dependencies ───────────────────────────────────────────────────────
# GStreamer runtime + GObject Introspection typelibs needed at runtime.
# libgirepository + libglib dev headers + gcc needed to compile PyGObject.
# gosu: used by entrypoint to drop from root → mopidy after fixing permissions.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
curl gnupg ca-certificates \ # GStreamer runtime
# Add Mopidy apt repo
&& curl -1sLf 'https://dl.cloudsmith.io/public/mopidy/mopidy/setup.deb.sh' \
| bash \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
mopidy \
# GStreamer pipeline (needed for filesink output to FIFO)
gstreamer1.0-plugins-good \ gstreamer1.0-plugins-good \
gstreamer1.0-plugins-bad \ gstreamer1.0-plugins-bad \
gstreamer1.0-plugins-ugly \ gstreamer1.0-plugins-ugly \
gstreamer1.0-tools \ gstreamer1.0-tools \
# Python / pip for Mopidy extensions libgstreamer1.0-0 \
python3-pip \ libgstreamer-plugins-base1.0-0 \
python3-gst-1.0 \ # GObject Introspection typelibs (used by PyGObject at runtime)
gir1.2-gstreamer-1.0 \
gir1.2-gst-plugins-base-1.0 \
# Build deps for PyGObject (can't be removed — runtime .so links against them)
libgirepository-2.0-dev \
libglib2.0-dev \
libcairo2-dev \
gcc \
pkg-config \
# Misc
curl \
gettext-base \ gettext-base \
gosu \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Mopidy Python extensions # ── Python packages ───────────────────────────────────────────────────────────
# Add or remove plugins to suit your setup. # PyGObject must be compiled first so pip can resolve mopidy's gst dependency.
RUN pip3 install --break-system-packages \ # setuptools provides pkg_resources (no longer bundled in Python 3.13).
mopidy-local \ # mopidy-spotify 5.x uses the Spotify Web API (no libspotify C extension).
mopidy-mpd \ RUN pip install --no-cache-dir PyGObject \
mopidy-youtube \ && pip install --no-cache-dir \
yt-dlp "setuptools<72" \
mopidy \
mopidy-local \
mopidy-mpd \
mopidy-youtube \
"mopidy-spotify>=5.0.0" \
yt-dlp
# Mopidy runs as the mopidy user (created by the package) # ── Runtime user ──────────────────────────────────────────────────────────────
RUN mkdir -p /var/lib/mopidy /config \ # Entrypoint runs as root, fixes volume ownership, then execs as mopidy via gosu.
RUN groupadd -r audio 2>/dev/null || true \
&& useradd -r -g audio -d /var/lib/mopidy -s /sbin/nologin mopidy \
&& mkdir -p /var/lib/mopidy /config \
&& chown -R mopidy:audio /var/lib/mopidy /config && chown -R mopidy:audio /var/lib/mopidy /config
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
EXPOSE 6680 EXPOSE 6680
USER mopidy
HEALTHCHECK --interval=20s --timeout=5s --start-period=15s \ HEALTHCHECK --interval=20s --timeout=5s --start-period=30s \
CMD curl -sf http://localhost:6680/mopidy/rpc \ CMD curl -sf http://localhost:6680/mopidy/rpc \
-d '{"jsonrpc":"2.0","id":1,"method":"core.get_version"}' \ -d '{"jsonrpc":"2.0","id":1,"method":"core.get_version"}' \
-H 'Content-Type: application/json' | grep -q '"id": 1' || exit 1 -H 'Content-Type: application/json' | grep -q '"id": 1' || exit 1
+18 -3
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Mopidy entrypoint # Mopidy entrypoint — runs as root, fixes permissions, then drops to mopidy user.
# Ensures the audio FIFO exists (Snapserver must open it too, ordering matters)
set -e set -e
# Fix ownership of the data volume (may be stale from a previous container build
# with a different mopidy uid, or from Docker initialising a new volume as root).
chown -R mopidy:audio /var/lib/mopidy 2>/dev/null || true
FIFO=/audio/mopidy.fifo FIFO=/audio/mopidy.fifo
# Create FIFO if missing (init-pipes.sh should have done this, but be safe) # Create FIFO if missing (init-pipes.sh should have done this, but be safe)
@@ -14,9 +17,21 @@ fi
echo "[mopidy] FIFO ready: $FIFO" echo "[mopidy] FIFO ready: $FIFO"
# Load Spotify credentials from shared config volume (written by the settings UI).
# Values here take precedence over env vars from docker-compose / .env.
_SPOTIFY_JSON=/config/shared/spotify.json
if [[ -f "$_SPOTIFY_JSON" ]]; then
_ID=$(python3 -c "import json; d=json.load(open('$_SPOTIFY_JSON')); print(d.get('client_id',''))" 2>/dev/null || true)
_SEC=$(python3 -c "import json; d=json.load(open('$_SPOTIFY_JSON')); print(d.get('client_secret',''))" 2>/dev/null || true)
[[ -n "$_ID" ]] && export SPOTIFY_CLIENT_ID="$_ID"
[[ -n "$_SEC" ]] && export SPOTIFY_CLIENT_SECRET="$_SEC"
echo "[mopidy] Spotify credentials loaded from shared config"
fi
# Expand only our own vars — leave Mopidy's $XDG_* vars untouched # Expand only our own vars — leave Mopidy's $XDG_* vars untouched
envsubst '${SPOTIFY_CLIENT_ID}${SPOTIFY_CLIENT_SECRET}${SNAPSERVER_HOST}${BEETS_HOST}' \ envsubst '${SPOTIFY_CLIENT_ID}${SPOTIFY_CLIENT_SECRET}${SNAPSERVER_HOST}${BEETS_HOST}' \
< /config/mopidy.conf > /tmp/mopidy.conf < /config/mopidy.conf > /tmp/mopidy.conf
echo "[mopidy] Config expanded from template" echo "[mopidy] Config expanded from template"
exec "$@" # Drop from root → mopidy user for the actual Mopidy process
exec gosu mopidy "$@"
+3 -5
View File
@@ -131,11 +131,9 @@ enabled = true
hostname = 0.0.0.0 hostname = 0.0.0.0
connection_timeout = 120 connection_timeout = 120
#[spotify] [spotify]
# mopidy-spotify requires pyspotify which has no arm64 build (libspotify was client_id = ${SPOTIFY_CLIENT_ID}
# discontinued by Spotify). Spotify playback is handled via librespot/snapserver. client_secret = ${SPOTIFY_CLIENT_SECRET}
#client_id = ${SPOTIFY_CLIENT_ID}
#client_secret = ${SPOTIFY_CLIENT_SECRET}
[muse] [muse]
enabled = true enabled = true
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""
bandcamp-sync — Download new Bandcamp purchases (WAV) to the local music library,
then invoke bandcamp-tag.py to fill in metadata.
Usage:
bandcamp-sync.py [MUSIC_DIR] # sync new purchases to MUSIC_DIR
bandcamp-sync.py --dry-run # show what would be downloaded, no writes
bandcamp-sync.py --list # list full collection, no download
Credentials (first match wins):
1. Env vars BANDCAMP_EMAIL / BANDCAMP_PASSWORD
2. bandcamp-api service at http://localhost:8091/credentials
3. JSON file ~/.config/audioserver/bandcamp.json { "email": ..., "password": ... }
"""
import argparse
import html
import json
import os
import re
import shutil
import subprocess
import sys
import time
import zipfile
from pathlib import Path
from urllib.parse import unquote
import requests
# ── Config ────────────────────────────────────────────────────────────────────
DEFAULT_MUSIC_DIR = Path("/mnt/local/music")
SCRIPT_DIR = Path(__file__).parent
TAGGER = SCRIPT_DIR / "bandcamp-tag.py"
REQUEST_DELAY = 1.0 # seconds between Bandcamp requests
COLLECTION_BATCH = 50
SESSION = requests.Session()
SESSION.headers["User-Agent"] = (
"Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
# ── Credentials ───────────────────────────────────────────────────────────────
def load_credentials() -> tuple[str, str]:
email = os.environ.get("BANDCAMP_EMAIL", "")
password = os.environ.get("BANDCAMP_PASSWORD", "")
if email and password:
return email, password
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
except Exception:
pass
cfg_file = Path.home() / ".config" / "audioserver" / "bandcamp.json"
if cfg_file.exists():
with open(cfg_file) as f:
cfg = json.load(f)
email = cfg.get("email", email)
password = cfg.get("password", password)
# 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():
try:
with open(docker_cfg) as f:
cfg = json.load(f)
email = cfg.get("email", email)
password = cfg.get("password", password)
except Exception:
pass
return email, password
# ── Bandcamp auth ─────────────────────────────────────────────────────────────
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:
raise RuntimeError("Could not get CSRF token from Bandcamp login page")
time.sleep(REQUEST_DELAY)
r = SESSION.post(
"https://bandcamp.com/login_cb",
data={"username": email, "password": password, "csrf_token": csrf},
headers={
"X-CSRFToken": csrf,
"Referer": "https://bandcamp.com/login",
"Accept": "application/json, text/javascript, */*",
"Origin": "https://bandcamp.com",
},
timeout=15,
)
payload = r.json()
if payload.get("ok") != 1:
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 get_fan_id(account: dict) -> int:
fan_id = account.get("fan_id") or account.get("id")
if fan_id:
return int(fan_id)
username = account.get("username", "")
if username:
time.sleep(REQUEST_DELAY)
r = SESSION.get(f"https://bandcamp.com/{username}", timeout=15)
m = re.search(r'"fan_id"\s*:\s*(\d+)', r.text)
if m:
return int(m.group(1))
m = re.search(r'data-fan-id="(\d+)"', r.text)
if m:
return int(m.group(1))
raise RuntimeError("Could not determine fan_id — check credentials")
# ── Collection ────────────────────────────────────────────────────────────────
def fetch_collection(fan_id: int) -> list[dict]:
"""Fetch all purchased items from the fan collection."""
items = []
token = "9999999999:0:a::"
while True:
time.sleep(REQUEST_DELAY)
r = SESSION.post(
"https://bandcamp.com/api/fancollection/1/collection_items",
json={"fan_id": fan_id, "older_than_token": token, "count": COLLECTION_BATCH},
timeout=15,
)
r.raise_for_status()
data = r.json()
batch = data.get("items") or data.get("redownload_urls") or []
items.extend(batch)
if not data.get("more_available") or not batch:
break
token = data.get("last_token", "")
if not token:
break
return items
# ── Local library index ───────────────────────────────────────────────────────
def _norm(s: str) -> str:
"""Normalize for loose matching (lowercase, strip punctuation/spaces)."""
return re.sub(r"[^a-z0-9]", "", s.lower())
def index_local(music_dir: Path) -> set[str]:
"""Return a set of normalized 'artist||album' keys for existing folders."""
keys = set()
for entry in music_dir.iterdir():
if not entry.is_dir():
continue
name = entry.name
if " - " in name:
artist, album = name.split(" - ", 1)
keys.add(f"{_norm(artist)}||{_norm(album)}")
else:
keys.add(f"||{_norm(name)}")
return keys
def item_key(item: dict) -> str:
artist = item.get("band_name", "")
album = item.get("album_title", "") or item.get("item_title", "")
return f"{_norm(artist)}||{_norm(album)}"
def item_label(item: dict) -> str:
artist = item.get("band_name", "")
album = item.get("album_title", "") or item.get("item_title", "")
return f"{artist} - {album}"
# ── Download ──────────────────────────────────────────────────────────────────
def _parse_blob(html_text: str) -> dict:
"""Extract and parse the data-blob JSON from a Bandcamp page."""
m = re.search(r'data-blob="([^"]+)"', html_text)
if not m:
m = re.search(r"data-blob='([^']+)'", html_text)
if not m:
raise RuntimeError("data-blob not found on download page")
return json.loads(html.unescape(m.group(1)))
def get_wav_url(sale_item_id: int) -> str | None:
"""Return the WAV download URL for a purchase, or None if unavailable."""
time.sleep(REQUEST_DELAY)
r = SESSION.get(
"https://bandcamp.com/download",
params={
"from": "collection",
"payment_id": sale_item_id,
"stp": "gen",
"type": "album",
},
timeout=15,
)
if r.status_code != 200:
return None
try:
blob = _parse_blob(r.text)
except Exception as exc:
print(f" ✗ Could not parse data-blob: {exc}")
return None
for dl_item in blob.get("download_items", []):
downloads = dl_item.get("downloads", {})
for fmt in ("wav", "WAV"):
entry = downloads.get(fmt)
if entry and entry.get("url"):
return entry["url"]
return None
def download_and_extract(url: str, dest_dir: Path, label: str) -> Path | None:
"""Download a ZIP from url, extract to dest_dir/<Artist - Album>/."""
tmp_zip = dest_dir / f"_download_{os.getpid()}.zip"
tmp_extract = dest_dir / f"_extract_{os.getpid()}"
try:
print(f" Downloading {label}", end="", flush=True)
with SESSION.get(url, stream=True, timeout=120, allow_redirects=True) as r:
r.raise_for_status()
total = int(r.headers.get("Content-Length", 0))
downloaded = 0
with open(tmp_zip, "wb") as f:
for chunk in r.iter_content(chunk_size=1024 * 256):
f.write(chunk)
downloaded += len(chunk)
print(f" {downloaded // 1024 // 1024} MB")
tmp_extract.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(tmp_zip) as z:
z.extractall(tmp_extract)
# Find the album folder inside the extracted ZIP
subdirs = [p for p in tmp_extract.iterdir() if p.is_dir()]
if subdirs:
album_src = subdirs[0]
else:
album_src = tmp_extract # files in root of ZIP
# Build destination folder name from the label
safe_label = re.sub(r'[<>:"/\\|?*]', "-", label)
final_dest = dest_dir / safe_label
if final_dest.exists():
shutil.rmtree(final_dest)
shutil.move(str(album_src), str(final_dest))
return final_dest
except Exception as exc:
print(f" ✗ Download failed: {exc}")
return None
finally:
if tmp_zip.exists():
tmp_zip.unlink()
if tmp_extract.exists():
shutil.rmtree(tmp_extract, ignore_errors=True)
def run_tagger(album_dir: Path):
"""Run bandcamp-tag.py on a single album folder."""
if not TAGGER.exists():
return
print(f" Tagging…")
result = subprocess.run(
[sys.executable, str(TAGGER), "--album", album_dir.name, str(album_dir.parent)],
capture_output=True, text=True,
)
for line in result.stdout.strip().splitlines():
print(f" {line}")
if result.returncode != 0:
for line in result.stderr.strip().splitlines():
print(f" ERR {line}")
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Sync Bandcamp purchases to local library")
parser.add_argument("music_dir", nargs="?", type=Path, default=DEFAULT_MUSIC_DIR)
parser.add_argument("--dry-run", action="store_true", help="Show plan only, no downloads")
parser.add_argument("--list", action="store_true", help="List collection, exit")
parser.add_argument("--no-tag", action="store_true", help="Skip bandcamp-tag.py after download")
args = parser.parse_args()
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)
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})")
print("Fetching collection…")
items = fetch_collection(fan_id)
print(f" {len(items)} purchases found")
if args.list:
for item in items:
print(f" {item_label(item)}")
return
if not music_dir.exists():
print(f"✗ Music directory not found: {music_dir}")
sys.exit(1)
local = index_local(music_dir)
print(f" {len(local)} albums already in {music_dir}")
# Only consider album purchases (not singles / merch)
album_items = [i for i in items if i.get("item_type") in ("album", None)]
new_items = [i for i in album_items if item_key(i) not in local]
if not new_items:
print("✓ Library is up to date — nothing to download")
return
print(f"\n{len(new_items)} new album(s) to download:")
for item in new_items:
print(f"{item_label(item)}")
if args.dry_run:
return
print()
ok = 0
for item in new_items:
label = item_label(item)
sale_id = item.get("sale_item_id") or item.get("payment_id")
if not sale_id:
print(f" [{label}] — no sale_item_id, skipping")
continue
print(f" [{label}]")
wav_url = get_wav_url(int(sale_id))
if not wav_url:
print(f" ✗ No WAV download available (check Bandcamp — may need to choose format)")
continue
album_dir = download_and_extract(wav_url, music_dir, label)
if album_dir is None:
continue
if not args.no_tag and TAGGER.exists():
run_tagger(album_dir)
ok += 1
print(f"\n{ok}/{len(new_items)} album(s) downloaded")
if __name__ == "__main__":
main()
+597
View File
@@ -0,0 +1,597 @@
#!/usr/bin/env python3
"""
bandcamp-tag — Tag Bandcamp WAV downloads using Bandcamp metadata.
For each 'Artist - Album' folder in MUSIC_DIR:
1. Checks which ID3 tags are missing (year, genre, track total, art)
2. Searches Bandcamp for the release (or uses --url)
3. Fetches TralbumData JSON from the Bandcamp album page
4. Writes missing tags and embeds album art using mutagen
Usage:
bandcamp-tag.py [MUSIC_DIR] # scan all albums
bandcamp-tag.py --album "Artist - Title" # one album
bandcamp-tag.py --album "..." --url https://artist.bandcamp.com/album/slug
bandcamp-tag.py --dry-run # show plan without writing
bandcamp-tag.py --force # rewrite tags even if present
bandcamp-tag.py --scan # just list what needs updating
Dependencies: mutagen, requests (both already installed)
"""
import argparse
import html
import json
import os
import re
import sys
import time
import xml.etree.ElementTree as ET
from pathlib import Path
import requests
from mutagen.id3 import (
APIC, TALB, TCON, TDRC, TIT2, TPOS, TPE1, TPE2, TRCK,
)
from mutagen.wave import WAVE
# ── Config ────────────────────────────────────────────────────────────────────
DEFAULT_MUSIC_DIR = Path('/mnt/local/music')
REQUEST_DELAY = 0.8 # seconds between Bandcamp requests
ART_MAX_BYTES = 3 * 1024 * 1024 # skip art larger than 3 MB
SESSION = requests.Session()
SESSION.headers['User-Agent'] = (
'Mozilla/5.0 (X11; Linux aarch64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
)
# ── Bandcamp fetch ────────────────────────────────────────────────────────────
def _bc_slug(s: str) -> str:
"""Convert a string to a Bandcamp-style URL slug."""
s = s.lower().strip()
s = re.sub(r'[^a-z0-9 -]', '', s)
s = re.sub(r'\s+', '-', s)
return s.strip('-')
def _bc_subdomains(artist: str) -> list[str]:
"""Generate plausible Bandcamp subdomain candidates for an artist name."""
base = re.sub(r'[^a-z0-9]', '', artist.lower()) # no-spaces, no-hyphens
hyph = _bc_slug(artist) # with hyphens
candidates = []
for v in (base, hyph, base + 'music', base + 'dnb',
hyph + 'music', base.replace('-', '')):
if v and v not in candidates:
candidates.append(v)
return candidates
def bandcamp_search(artist: str, album: str) -> str | None:
"""
Return the URL of the best Bandcamp album match, or None.
Strategy: generate plausible subdomain + album-slug combinations and
probe them with HEAD requests (fast, no JS rendering needed).
"""
album_slug = _bc_slug(album)
subdomains = _bc_subdomains(artist)
for sub in subdomains:
url = f'https://{sub}.bandcamp.com/album/{album_slug}'
try:
r = SESSION.head(url, timeout=8, allow_redirects=True)
if r.status_code == 200:
return str(r.url)
except Exception:
pass
return None
def fetch_tralbum(page_url: str) -> dict | None:
"""Fetch and parse TralbumData JSON from a Bandcamp album page."""
try:
r = SESSION.get(page_url, timeout=20)
r.raise_for_status()
except Exception as e:
print(f' [fetch error] {e}')
return None
text = r.text
data = None
# Primary: data-tralbum attribute (HTML-entity-encoded JSON)
m = re.search(r'data-tralbum="([^"]*)"', text)
if m:
try:
data = json.loads(html.unescape(m.group(1)))
except json.JSONDecodeError:
pass
# Fallback: inline TralbumData variable
if data is None:
m = re.search(r'TralbumData\s*=\s*(\{.*?\});\s*(?:\n|$)', text, re.DOTALL)
if m:
try:
data = json.loads(m.group(1))
except json.JSONDecodeError:
pass
# Fallback: JSON-LD
if data is None:
for m in re.finditer(
r'<script[^>]+type="application/ld\+json"[^>]*>(.*?)</script>',
text, re.DOTALL,
):
try:
ld = json.loads(m.group(1))
if ld.get('@type') == 'MusicAlbum':
data = _ld_to_tralbum(ld)
break
except (json.JSONDecodeError, KeyError):
pass
if data is None:
return None
# If tags are missing from TralbumData, scrape them from the page HTML
if not (data.get('tags') or (data.get('current') or {}).get('tags')):
page_tags = re.findall(r'class="tag"[^>]*>([^<]+)<', text)
if page_tags:
data['tags'] = [t.strip() for t in page_tags if t.strip()]
return data
def _ld_to_tralbum(ld: dict) -> dict:
"""Convert JSON-LD MusicAlbum to a minimal TralbumData-style dict."""
tracks = []
for item in (ld.get('track') or {}).get('itemListElement') or []:
rec = item.get('item', {})
tracks.append({
'title': rec.get('name', ''),
'track_num': item.get('position', len(tracks) + 1),
'duration': None,
'disc_number': 1,
})
pub = ld.get('datePublished', '')
return {
'title': ld.get('name', ''),
'artist': (ld.get('byArtist') or {}).get('name', ''),
'art_id': None,
'current': {
'publish_date': pub,
'tags': [],
},
'tags': [],
'trackinfo': tracks,
}
def fetch_art(art_id: int) -> tuple[bytes | None, str]:
"""Download album art from Bandcamp CDN. Returns (bytes, mime) or (None, '')."""
for size in ('10', '0'): # 10 = 1200px max, 0 = original
url = f'https://f4.bcbits.com/img/a{art_id}_{size}.jpg'
try:
r = SESSION.get(url, timeout=30, stream=True)
if not r.ok:
continue
mime = r.headers.get('content-type', 'image/jpeg').split(';')[0].strip()
if not mime.startswith('image/'):
continue
data = r.content
if len(data) > ART_MAX_BYTES:
continue
if len(data) > 1000:
return data, mime
except Exception:
pass
return None, ''
# ── NFO helper ────────────────────────────────────────────────────────────────
def parse_nfo(nfo_path: Path) -> dict:
"""Parse a Kodi/Jellyfin album.nfo into a minimal TralbumData-style dict."""
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
def text(tag):
el = root.find(tag)
return el.text.strip() if el is not None and el.text else ''
tracks = []
for t in root.findall('track'):
pos_el = t.find('position')
title_el = t.find('title')
pos = int(pos_el.text) if pos_el is not None and pos_el.text else len(tracks) + 1
title = title_el.text.strip() if title_el is not None and title_el.text else ''
tracks.append({'title': title, 'track_num': pos, 'duration': None, 'disc_number': 1})
return {
'title': text('title'),
'artist': text('albumartist') or text('artist'),
'art_id': None,
'current': {'publish_date': text('year'), 'tags': [text('genre')] if text('genre') else []},
'tags': [text('genre')] if text('genre') else [],
'trackinfo': tracks,
}
except Exception:
return {}
# ── File helpers ──────────────────────────────────────────────────────────────
def parse_folder_name(name: str) -> tuple[str, str] | tuple[None, None]:
"""Split 'Artist - Album' folder name into (artist, album)."""
parts = name.split(' - ', 1)
if len(parts) == 2:
return parts[0].strip(), parts[1].strip()
return None, None
def parse_track_filename(stem: str, folder_name: str) -> tuple[int | None, str]:
"""
Return (track_number, title_guess) from a WAV filename stem.
Input examples:
'ALIX PEREZ - Gloom EP - 01 Psychosis'
'01 Psychosis'
"""
# Strip leading 'Artist - Album - '
prefix = folder_name + ' - '
if stem.startswith(prefix):
stem = stem[len(prefix):]
m = re.match(r'^(\d{1,3})\s+(.+)$', stem)
if m:
return int(m.group(1)), m.group(2).strip()
return None, stem.strip()
def tag_completeness(wav_path: Path) -> dict:
"""Return dict of missing tag names for a WAV file."""
missing = {}
try:
f = WAVE(wav_path)
tags = f.tags or {}
def has(prefix):
return any(k.startswith(prefix) for k in tags.keys())
def val(key):
v = tags.get(key)
return str(v[0]) if v else ''
if not val('TIT2'):
missing['title'] = True
if not val('TALB'):
missing['album'] = True
if not val('TPE1'):
missing['artist'] = True
if not val('TDRC'):
missing['year'] = True
if not val('TCON'):
missing['genre'] = True
if not has('APIC'):
missing['art'] = True
trck = val('TRCK')
if trck and '/' not in trck:
missing['track_total'] = True
except Exception:
missing['read_error'] = True
return missing
def load_folder_art(folder: Path) -> tuple[bytes | None, str]:
"""Try to load cover art from a folder."""
candidates = ['cover.jpg', 'cover.jpeg', 'cover.png', 'folder.jpg', 'folder.png']
for name in candidates:
p = folder / name
if p.exists():
try:
data = p.read_bytes()
mime = 'image/jpeg' if name.endswith(('.jpg', '.jpeg')) else 'image/png'
return data, mime
except Exception:
pass
return None, ''
def write_tags(
wav_path: Path,
bc_track: dict,
bc_album: dict,
art_bytes: bytes | None,
art_mime: str,
track_total: int,
missing: dict,
dry_run: bool,
force: bool,
) -> None:
title = bc_track.get('title', '')
track_num = bc_track.get('track_num') or 1
disc_num = bc_track.get('disc_number') or 1
current = bc_album.get('current') or {}
album_str = current.get('title') or bc_album.get('title', '')
artist = bc_track.get('artist') or bc_album.get('artist', '')
tags_list = current.get('tags') or bc_album.get('tags') or []
genre = tags_list[0] if tags_list else ''
pub = current.get('publish_date', '')
year = re.search(r'\d{4}', pub).group(0) if re.search(r'\d{4}', pub) else ''
if dry_run:
updates = []
if force or missing.get('title'): updates.append(f'title={title!r}')
if force or missing.get('album'): updates.append(f'album={album_str!r}')
if force or missing.get('artist'): updates.append(f'artist={artist!r}')
if force or missing.get('year'): updates.append(f'year={year!r}')
if force or missing.get('genre'): updates.append(f'genre={genre!r}')
if force or missing.get('track_total'): updates.append(f'trck={track_num}/{track_total}')
if (force or missing.get('art')) and art_bytes: updates.append('art')
print(f' [dry] {wav_path.name}: {", ".join(updates) or "no changes"}')
return
try:
f = WAVE(wav_path)
if f.tags is None:
f.add_tags()
t = f.tags
def set_tag(key, cls, **kw):
for k in list(t.keys()):
if k == key or k.startswith(key + ':'):
del t[k]
t[key] = cls(**kw)
if force or missing.get('title') and title:
set_tag('TIT2', TIT2, text=[title])
if force or missing.get('album') and album_str:
set_tag('TALB', TALB, text=[album_str])
if force or missing.get('artist') and artist:
set_tag('TPE1', TPE1, text=[artist])
set_tag('TPE2', TPE2, text=[bc_album.get('artist', artist)])
if (force or missing.get('year')) and year:
set_tag('TDRC', TDRC, text=[year])
if (force or missing.get('genre')) and genre:
set_tag('TCON', TCON, text=[genre])
if force or missing.get('track_total'):
set_tag('TRCK', TRCK, text=[f'{track_num}/{track_total}'])
if disc_num > 1:
set_tag('TPOS', TPOS, text=[str(disc_num)])
if (force or missing.get('art')) and art_bytes:
for k in list(t.keys()):
if k.startswith('APIC'):
del t[k]
t['APIC:cover'] = APIC(
mime=art_mime, type=3, desc='cover', data=art_bytes,
)
f.save()
print(f' OK {wav_path.name}')
except Exception as e:
print(f' ERR {wav_path.name}: {e}')
# ── Title similarity (for fuzzy track matching) ───────────────────────────────
def _jaccard(a: str, b: str) -> float:
wa = set(re.findall(r'\w+', a.lower()))
wb = set(re.findall(r'\w+', b.lower()))
if not wa or not wb:
return 0.0
return len(wa & wb) / len(wa | wb)
# ── Album processing ──────────────────────────────────────────────────────────
def process_album(
folder: Path,
url: str | None = None,
force: bool = False,
dry_run: bool = False,
scan_only: bool = False,
) -> bool:
"""Tag all WAV files in one album folder. Returns True if any work was done."""
wav_files = sorted(folder.glob('*.wav'))
if not wav_files:
return False
folder_name = folder.name
artist, album = parse_folder_name(folder_name)
if not artist:
print(f'SKIP {folder_name} (can\'t parse artist/album)')
return False
# Check which tags are missing across all WAVs
all_missing: dict[str, bool] = {}
for wf in wav_files:
for k, v in tag_completeness(wf).items():
if v:
all_missing[k] = True
if not force and not all_missing:
if not scan_only:
print(f'OK {folder_name}')
return False
missing_str = ', '.join(sorted(all_missing)) or 'none'
print(f'NEED {folder_name} [{missing_str}]')
if scan_only:
return True
# ── Load metadata source ──────────────────────────────────────────────────
bc_data: dict | None = None
# Try .nfo first (no network needed for title/genre/tracks)
nfo_path = folder / 'album.nfo'
if nfo_path.exists():
nfo = parse_nfo(nfo_path)
if nfo.get('trackinfo'):
print(f' Using .nfo: {nfo_path.name}')
bc_data = nfo
# Fetch from Bandcamp if still missing year, genre, or we have no data yet
still_need_bc = (
bc_data is None
or (force or all_missing.get('year')) and not (bc_data.get('current') or {}).get('publish_date', '').strip()
or (force or all_missing.get('genre')) and not ((bc_data.get('current') or {}).get('tags') or bc_data.get('tags'))
or all_missing.get('art') and bc_data.get('art_id') is None
)
if still_need_bc:
if not url:
print(f' Searching Bandcamp for {artist!r}{album!r}')
url = bandcamp_search(artist, album)
time.sleep(REQUEST_DELAY)
if not url:
print(f' No Bandcamp result. Use --url to provide one.')
if bc_data is None:
return False
else:
print(f' Found: {url}')
if url:
fetched = fetch_tralbum(url)
time.sleep(REQUEST_DELAY)
if fetched:
# Merge: prefer Bandcamp data but keep .nfo track list if Bandcamp has none
if bc_data and bc_data.get('trackinfo') and not fetched.get('trackinfo'):
fetched['trackinfo'] = bc_data['trackinfo']
bc_data = fetched
elif bc_data is None:
print(f' Could not fetch album data.')
return False
if bc_data is None:
return False
# ── Album art ─────────────────────────────────────────────────────────────
art_bytes: bytes | None = None
art_mime = 'image/jpeg'
if force or all_missing.get('art'):
art_id = bc_data.get('art_id') or (bc_data.get('current') or {}).get('art_id')
if art_id:
print(f' Downloading art (id={art_id}) …')
art_bytes, art_mime = fetch_art(art_id)
time.sleep(REQUEST_DELAY)
if art_bytes:
print(f' Art: {len(art_bytes) // 1024} KB ({art_mime})')
else:
print(f' Art download failed, falling back to folder art')
if not art_bytes:
art_bytes, art_mime = load_folder_art(folder)
if art_bytes:
print(f' Using folder art ({art_mime}, {len(art_bytes)//1024} KB)')
# ── Build track map ───────────────────────────────────────────────────────
bc_tracks_by_num: dict[int, dict] = {}
for t in bc_data.get('trackinfo') or []:
n = t.get('track_num')
if n:
bc_tracks_by_num[n] = t
total = len(bc_data.get('trackinfo') or []) or len(wav_files)
# ── Tag each file ─────────────────────────────────────────────────────────
for wav_file in wav_files:
stem = wav_file.stem
track_num, title_guess = parse_track_filename(stem, folder_name)
file_missing = tag_completeness(wav_file)
if not force and not file_missing:
print(f' -- {wav_file.name} (complete)')
continue
# Match to Bandcamp track
if track_num and track_num in bc_tracks_by_num:
bc_track = bc_tracks_by_num[track_num]
elif bc_tracks_by_num:
# Fuzzy match by title
best = max(
bc_tracks_by_num.values(),
key=lambda t: _jaccard(t.get('title', ''), title_guess),
)
bc_track = best
else:
bc_track = {'title': title_guess, 'track_num': track_num or 1, 'disc_number': 1}
write_tags(
wav_file, bc_track, bc_data,
art_bytes, art_mime, total,
file_missing, dry_run, force,
)
return True
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> None:
ap = argparse.ArgumentParser(
description='Tag Bandcamp WAV downloads with metadata and album art.',
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument(
'music_dir', nargs='?', default=str(DEFAULT_MUSIC_DIR),
help=f'Music directory to scan (default: {DEFAULT_MUSIC_DIR})',
)
ap.add_argument('--album', metavar='NAME',
help='Process only this folder name (relative to MUSIC_DIR or absolute)')
ap.add_argument('--url', metavar='URL',
help='Bandcamp album URL (use with --album to skip search)')
ap.add_argument('--force', action='store_true',
help='Rewrite all tags even if already present')
ap.add_argument('--dry-run', action='store_true',
help='Show planned changes without writing anything')
ap.add_argument('--scan', action='store_true',
help='List albums that need updating and exit')
args = ap.parse_args()
music_dir = Path(args.music_dir)
if not music_dir.is_dir():
sys.exit(f'Error: {music_dir} is not a directory')
if args.album:
target = Path(args.album)
if not target.is_absolute():
target = music_dir / args.album
if not target.is_dir():
sys.exit(f'Error: {target} is not a directory')
process_album(target, url=args.url, force=args.force,
dry_run=args.dry_run, scan_only=args.scan)
return
folders = sorted(d for d in music_dir.iterdir() if d.is_dir())
wav_folders = [d for d in folders if any(d.glob('*.wav'))]
print(f'Scanning {len(wav_folders)} album folders in {music_dir}\n')
needs_work = 0
for folder in wav_folders:
worked = process_album(folder, force=args.force,
dry_run=args.dry_run, scan_only=args.scan)
if worked:
needs_work += 1
if not args.scan:
time.sleep(0.3)
if args.scan:
print(f'\n{needs_work} of {len(wav_folders)} albums need updating.')
else:
print(f'\nDone. {needs_work} albums processed.')
if __name__ == '__main__':
main()