Spotify integration added
This commit is contained in:
@@ -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
|
||||||
|
|||||||
-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.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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
- ./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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
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