Spotify integration added
This commit is contained in:
@@ -22,8 +22,8 @@ TZ=Europe/Vienna
|
||||
HIFIBERRY_CARD=sndrpihifiberry
|
||||
|
||||
# Spotify credentials for mopidy-spotify
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
SPOTIFY_CLIENT_ID=b168b3fa4c124a5e91cfeb6c2af23fc0
|
||||
SPOTIFY_CLIENT_SECRET=e357609b4aa74752aca03a9ba58c3978
|
||||
|
||||
# IP/hostname of this Pi as seen by LAN clients (Snapserver HTTP host, Mopidy/Iris)
|
||||
SNAPSERVER_HOST=192.168.178.100
|
||||
|
||||
-40
File diff suppressed because one or more lines are too long
+40
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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" />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -595,14 +595,271 @@ function MediaBrowser({ onAdd }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserSection({ onAdd }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
function SourceBrowser({ onAdd }) {
|
||||
const [source, setSource] = useState('local');
|
||||
return (
|
||||
<div>
|
||||
<button className="section-toggle" onClick={() => setOpen(o => !o)}>
|
||||
BROWSE LIBRARY {open ? '▲' : '▼'}
|
||||
</button>
|
||||
{open && <MediaBrowser onAdd={onAdd} />}
|
||||
<div className="browser-mode-row">
|
||||
<button
|
||||
className={`browser-mode-btn ${source === 'local' ? 'browser-mode-btn--active' : ''}`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -768,8 +1025,9 @@ export function MopidyPanel() {
|
||||
|
||||
{/* Media browser */}
|
||||
<section className="panel-section">
|
||||
<BrowserSection onAdd={addTracksWithFeedback} />
|
||||
<SourceBrowser onAdd={addTracksWithFeedback} />
|
||||
</section>
|
||||
|
||||
</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 ────────────────────────────────────────────────────────────────
|
||||
export function SettingsPanel() {
|
||||
const { statuses, check } = useServiceHealth();
|
||||
@@ -200,6 +407,16 @@ export function SettingsPanel() {
|
||||
<ConnectionsSection />
|
||||
</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="section-label" style={{ marginBottom: 14 }}>MOPIDY</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
- ./mopidy/mopidy.conf:/config/mopidy.conf:ro
|
||||
- ${MUSIC_DIR:-/opt/audiocontrol/music}:/music:ro
|
||||
- mopidy_data:/var/lib/mopidy
|
||||
- bandcamp_config:/config/shared:ro
|
||||
- <<: *audio-volume
|
||||
depends_on:
|
||||
- snapserver
|
||||
@@ -55,6 +56,14 @@ services:
|
||||
- SNAPSERVER_HOST=${SNAPSERVER_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:
|
||||
build:
|
||||
@@ -77,6 +86,8 @@ services:
|
||||
depends_on:
|
||||
- snapserver
|
||||
- mopidy
|
||||
- bandcamp-api
|
||||
|
||||
volumes:
|
||||
mopidy_data:
|
||||
bandcamp_config:
|
||||
|
||||
@@ -72,6 +72,28 @@ server {
|
||||
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 ─────────────────────────────────────
|
||||
# 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
|
||||
|
||||
+39
-24
@@ -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 \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
curl gnupg ca-certificates \
|
||||
# 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)
|
||||
# GStreamer runtime
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-plugins-bad \
|
||||
gstreamer1.0-plugins-ugly \
|
||||
gstreamer1.0-tools \
|
||||
# Python / pip for Mopidy extensions
|
||||
python3-pip \
|
||||
python3-gst-1.0 \
|
||||
libgstreamer1.0-0 \
|
||||
libgstreamer-plugins-base1.0-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 \
|
||||
gosu \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Mopidy Python extensions
|
||||
# Add or remove plugins to suit your setup.
|
||||
RUN pip3 install --break-system-packages \
|
||||
mopidy-local \
|
||||
mopidy-mpd \
|
||||
mopidy-youtube \
|
||||
yt-dlp
|
||||
# ── Python packages ───────────────────────────────────────────────────────────
|
||||
# PyGObject must be compiled first so pip can resolve mopidy's gst dependency.
|
||||
# setuptools provides pkg_resources (no longer bundled in Python 3.13).
|
||||
# mopidy-spotify 5.x uses the Spotify Web API (no libspotify C extension).
|
||||
RUN pip install --no-cache-dir PyGObject \
|
||||
&& pip install --no-cache-dir \
|
||||
"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)
|
||||
RUN mkdir -p /var/lib/mopidy /config \
|
||||
# ── Runtime user ──────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
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 \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"core.get_version"}' \
|
||||
-H 'Content-Type: application/json' | grep -q '"id": 1' || exit 1
|
||||
|
||||
+18
-3
@@ -1,8 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Mopidy entrypoint
|
||||
# Ensures the audio FIFO exists (Snapserver must open it too, ordering matters)
|
||||
# Mopidy entrypoint — runs as root, fixes permissions, then drops to mopidy user.
|
||||
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
|
||||
|
||||
# Create FIFO if missing (init-pipes.sh should have done this, but be safe)
|
||||
@@ -14,9 +17,21 @@ fi
|
||||
|
||||
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
|
||||
envsubst '${SPOTIFY_CLIENT_ID}${SPOTIFY_CLIENT_SECRET}${SNAPSERVER_HOST}${BEETS_HOST}' \
|
||||
< /config/mopidy.conf > /tmp/mopidy.conf
|
||||
echo "[mopidy] Config expanded from template"
|
||||
|
||||
exec "$@"
|
||||
# Drop from root → mopidy user for the actual Mopidy process
|
||||
exec gosu mopidy "$@"
|
||||
|
||||
+3
-5
@@ -131,11 +131,9 @@ enabled = true
|
||||
hostname = 0.0.0.0
|
||||
connection_timeout = 120
|
||||
|
||||
#[spotify]
|
||||
# mopidy-spotify requires pyspotify which has no arm64 build (libspotify was
|
||||
# discontinued by Spotify). Spotify playback is handled via librespot/snapserver.
|
||||
#client_id = ${SPOTIFY_CLIENT_ID}
|
||||
#client_secret = ${SPOTIFY_CLIENT_SECRET}
|
||||
[spotify]
|
||||
client_id = ${SPOTIFY_CLIENT_ID}
|
||||
client_secret = ${SPOTIFY_CLIENT_SECRET}
|
||||
|
||||
[muse]
|
||||
enabled = true
|
||||
|
||||
Executable
+392
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user