spotify export: on-demand write-auth (restores Sync to Spotify safely, #945)

Brings back Spotify playlist export WITHOUT the regression that forced every user to re-auth.
The safety property: the global login scope (SPOTIFY_OAUTH_SCOPE) is NEVER changed, so no
existing token is invalidated. The write permission is requested only when a user actually
exports to Spotify.

- SPOTIFY_EXPORT_SCOPE = the global read scope + playlist-modify, used ONLY by the new
  /auth/spotify/export route. Spotify returns a superset token; the normal /callback exchanges
  and stores it unchanged (read ⊆ read+write keeps the standard auth check valid) — no callback
  changes needed.
- SpotifyClient.has_write_scope() checks the cached token for playlist-modify.
- start_playlist_export_service returns {needs_auth, auth_url} for Spotify when the token lacks
  write, instead of starting a doomed job. The modal opens the consent in a new tab and tells the
  user to retry once approved; the "Sync to Spotify" button is back, gated on connection as before.
- Release notes (pr_description / What's New / version modal / discord) restored to Spotify &
  Deezer with the one-time-permission note; discord back under 2000 chars (1983).

Tests: export scope is a strict superset of the (still read-only) global scope; has_write_scope
true/false for write/readonly/missing tokens and no-client. 275 spotify/oauth tests green, ruff
clean, 64 script-integrity green.
This commit is contained in:
BoulderBadgeDad 2026-06-29 08:32:19 -07:00
parent 6f451a34e1
commit f3672c7ab4
7 changed files with 135 additions and 15 deletions

View file

@ -1,6 +1,6 @@
**SoulSync 2.8.1** is out 🎉 a feature + reliability release.
🎧 **Export playlists to Deezer** — the mirrored-playlist export now has **Sync to Deezer** next to the ListenBrainz / JSPF options. it builds a Deezer playlist in your account from the IDs soulsync already has (the discovery cache first, then your library), so an already-discovered playlist exports **instantly with zero API calls**. re-exporting updates the same playlist instead of duplicating it, and an optional *"match missing tracks"* toggle confidently searches for the stragglers — a wrong-artist or karaoke version is left out, never guessed. (Spotify export is coming in a follow-up.) (#945)
🎧 **Export playlists to Spotify & Deezer** — the mirrored-playlist export now has **Sync to Spotify** and **Sync to Deezer** next to the ListenBrainz / JSPF options. it builds a playlist in your account from the IDs soulsync already has (the discovery cache first, then your library), so an already-discovered playlist exports **instantly with zero API calls**. re-exporting updates the same playlist instead of duplicating it, and an optional *"match missing tracks"* toggle confidently searches for the stragglers — a wrong-artist or karaoke version is left out, never guessed. the first Spotify export asks permission once. (#945)
🏷️ **Library Reorganize — Rename only** — a lighter action that just **renames your files** to your naming scheme: no re-tag, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, and only touches files whose path actually changes. pick it from the new Action dropdown. (#875 — thanks @tsoulard / @Tacobell444)

View file

@ -30,6 +30,16 @@ SPOTIFY_OAUTH_SCOPE = (
"playlist-read-collaborative user-read-email user-follow-read"
)
# The export scope = the normal login scope PLUS playlist write. Requested ONLY by the
# on-demand export-auth route (/auth/spotify/export) when a user chooses to export a playlist
# to Spotify — NEVER by the normal login. That's the whole safety property: the global scope
# above is unchanged, so no existing token is invalidated. The token Spotify returns from the
# export flow is a SUPERSET of the read scope, so it still passes the normal auth check
# (read ⊆ read+write) — one account, one token, just with write added for the opt-in user.
SPOTIFY_EXPORT_SCOPE = (
SPOTIFY_OAUTH_SCOPE + " playlist-modify-public playlist-modify-private"
)
def normalize_spotify_oauth_config(config: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Normalize Spotify OAuth config before building an auth manager.
@ -1153,6 +1163,19 @@ class SpotifyClient:
return playlists
return []
def has_write_scope(self) -> bool:
"""True when the cached Spotify token carries playlist-modify (the export write scope).
The export endpoint uses this to decide whether to run, or to first send the user
through the on-demand export-auth flow. Fail-safe: any error False (not authorized)."""
if self.sp is None:
return False
try:
cache_handler = getattr(self.sp.auth_manager, "cache_handler", None)
token = cache_handler.get_cached_token() if cache_handler else None
return "playlist-modify" in ((token or {}).get("scope") or "")
except Exception:
return False
def create_or_update_playlist(self, name, track_ids, *, existing_id=None,
public=False, description=""):
"""Create a Spotify playlist owned by the authed user (or replace an existing

View file

@ -1,19 +1,19 @@
# soulsync 2.8.1 — `dev``main`
a feature + reliability release. the headline is **export a mirrored playlist back to Deezer** — same one-click flow as the listenbrainz export, now pointed at your Deezer account (Spotify export is coming in a follow-up). plus a **rename-only mode** for Library Reorganize, broader lossless handling, a pile of download fixes, and the reduce-visual-effects pass refined so it stops freezing functional motion.
a feature + reliability release. the headline is **export a mirrored playlist back to Spotify or Deezer** — same one-click flow as the listenbrainz export, now pointed at the streaming services. plus a **rename-only mode** for Library Reorganize, broader lossless handling, a pile of download fixes, and the reduce-visual-effects pass refined so it stops freezing functional motion.
---
## what's new
### 🎧 Export playlists to Deezer (#945)
the mirrored-playlist export modal now has **Sync to Deezer** next to the listenbrainz / jspf options. it builds a Deezer playlist in your account from the tracks soulsync already has the Deezer IDs for:
### 🎧 Export playlists to Spotify & Deezer (#945)
the mirrored-playlist export modal now has **Sync to Spotify** and **Sync to Deezer** next to the listenbrainz / jspf options. it builds a playlist in your account from the tracks soulsync already has the service IDs for:
- resolves each track from what's already on hand first — the **discovery cache**, then your library's stored IDs — so for an already-discovered playlist it's instant and uses **zero API calls**
- re-exporting **updates the same playlist in place** instead of spawning duplicates
- an optional **"match missing tracks"** toggle does a confident live search for the stragglers — and only adds a match it's sure about (a wrong-artist or karaoke version is left out, never guessed)
- the Deezer button greys out + points you to Settings when Deezer isn't connected
- _Spotify export is wired up but waiting on an on-demand write-permission flow, so it's hidden for now_
- service buttons grey out + point you to Settings when that service isn't connected
- the **first** time you export to Spotify it asks permission to create playlists (just that once — your normal Spotify login is untouched, so upgrading doesn't disrupt anyone)
### 🏷️ Library Reorganize — Rename only (#875)
a lighter reorganize action: it just **renames your files** to your current naming scheme — no re-tagging, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, won't fail on post-processing reasons, and only touches files whose path actually changes (which also fixes the "2 of 14 previewed but everything got modified" album-splitting). pick it from the new **Action** dropdown in the reorganize modal.

View file

@ -193,3 +193,48 @@ def test_global_oauth_callbacks_use_db_token_cache_not_file():
src = open(_os.path.join(root, 'web_server.py'), encoding='utf-8').read()
assert "cache_path='config/.spotify_cache'" not in src # no global file-cache writes
assert src.count('cache_handler=DatabaseTokenCache(config_manager)') >= 2
# ── on-demand Spotify export write-auth (#945 follow-up) ──
def test_export_scope_is_global_plus_write_and_global_stays_readonly():
"""The export scope adds playlist-modify ON TOP of the unchanged global scope. Critically
the GLOBAL scope must NOT gain write (that's what force-invalidated everyone's token)."""
from core.spotify_client import SPOTIFY_OAUTH_SCOPE, SPOTIFY_EXPORT_SCOPE
assert 'playlist-modify' not in SPOTIFY_OAUTH_SCOPE # global stays read-only
assert 'playlist-modify-public' in SPOTIFY_EXPORT_SCOPE
assert 'playlist-modify-private' in SPOTIFY_EXPORT_SCOPE
# export scope is a strict superset of the global read scope
assert set(SPOTIFY_OAUTH_SCOPE.split()).issubset(set(SPOTIFY_EXPORT_SCOPE.split()))
class _CacheHandler:
def __init__(self, token):
self._token = token
def get_cached_token(self):
return self._token
def _client_with_token(token):
import types
c = _SpotifyClient.__new__(_SpotifyClient)
c.sp = types.SimpleNamespace(auth_manager=types.SimpleNamespace(cache_handler=_CacheHandler(token)))
return c
def test_has_write_scope_true_when_token_carries_playlist_modify():
tok = {'scope': 'user-library-read playlist-modify-public playlist-read-private'}
assert _client_with_token(tok).has_write_scope() is True
def test_has_write_scope_false_for_readonly_token_or_missing():
assert _client_with_token({'scope': 'user-library-read playlist-read-private'}).has_write_scope() is False
assert _client_with_token(None).has_write_scope() is False # no cached token
assert _client_with_token({}).has_write_scope() is False # token w/o scope field
def test_has_write_scope_false_when_no_client():
c = _SpotifyClient.__new__(_SpotifyClient)
c.sp = None
assert c.has_write_scope() is False

View file

@ -4940,6 +4940,38 @@ def auth_spotify():
logger.error(f"Error starting Spotify auth: {e}")
return f"<h1>Spotify Authentication Error</h1><p>{str(e)}</p>", 500
@app.route('/auth/spotify/export')
def auth_spotify_export():
"""On-demand authorization for Spotify playlist EXPORT (#945).
Requests the export scope (the normal read scope + playlist-modify) so the user can write
a playlist to their Spotify WITHOUT changing the global login scope, so no existing
token is invalidated. Spotify returns a superset token; the normal /callback exchanges and
stores it unchanged (read read+write keeps the standard auth check happy). show_dialog
forces the consent screen so the new write permission is actually granted."""
try:
from spotipy.oauth2 import SpotifyOAuth
from core.spotify_client import normalize_spotify_oauth_config, SPOTIFY_EXPORT_SCOPE
from core.spotify_token_cache import DatabaseTokenCache
cfg = normalize_spotify_oauth_config(config_manager.get_spotify_config())
if not cfg.get('client_id') or not cfg.get('client_secret'):
return "<h1>Spotify not configured</h1><p>Add your Spotify app credentials in Settings first.</p>", 400
auth_manager = SpotifyOAuth(
client_id=cfg['client_id'],
client_secret=cfg['client_secret'],
redirect_uri=cfg.get('redirect_uri', 'http://127.0.0.1:8888/callback'),
scope=SPOTIFY_EXPORT_SCOPE,
cache_handler=DatabaseTokenCache(config_manager),
show_dialog=True,
)
add_activity_item("", "Spotify Export Auth", "Requesting permission to create playlists", "Now")
return redirect(auth_manager.get_authorize_url())
except Exception as e:
logger.error(f"Error starting Spotify export auth: {e}")
return f"<h1>Spotify Export Authorization Error</h1><p>{str(e)}</p>", 500
@app.route('/auth/tidal')
def auth_tidal():
"""
@ -27920,6 +27952,15 @@ def start_playlist_export_service(playlist_id, service):
service = (service or '').lower()
if service not in ('spotify', 'deezer'):
return jsonify({"success": False, "error": f"Unsupported export target: {service}"}), 400
# Spotify export needs the write scope, which the normal login doesn't request. If the
# current token doesn't carry it, tell the UI to send the user through the one-time
# on-demand export-auth (instead of starting a job that would 403). #945.
if service == 'spotify' and not (spotify_client and spotify_client.has_write_scope()):
return jsonify({
"success": False, "needs_auth": True,
"auth_url": "/auth/spotify/export",
"error": "Spotify needs permission to create playlists",
}), 200
body = request.get_json(silent=True) or {}
backfill = bool(body.get('backfill'))
db = get_database()

View file

@ -3406,7 +3406,7 @@ const WHATS_NEW = {
// "Earlier versions" summary entry. Don't accumulate old per-version blocks.
'2.8.1': [
{ date: 'June 2026 — 2.8.1 release' },
{ title: 'Export playlists to Deezer (#945)', desc: 'the mirrored-playlist export modal now has Sync to Deezer next to the ListenBrainz/JSPF options. it builds a Deezer playlist in your account from the IDs soulsync already has — the discovery cache first, then your library — so an already-discovered playlist exports instantly with zero API calls. re-exporting updates the same playlist instead of duplicating it, and an optional "match missing tracks" toggle confidently searches Deezer for the stragglers (a wrong-artist or karaoke version is left out, never guessed). (Spotify export is coming in a follow-up.)', page: 'playlists' },
{ title: 'Export playlists to Spotify & Deezer (#945)', desc: 'the mirrored-playlist export modal now has Sync to Spotify and Sync to Deezer next to the ListenBrainz/JSPF options. it builds a playlist in your account from the IDs soulsync already has — the discovery cache first, then your library — so an already-discovered playlist exports instantly with zero API calls. re-exporting updates the same playlist instead of duplicating it, and an optional "match missing tracks" toggle confidently searches for the stragglers (a wrong-artist or karaoke version is left out, never guessed). the first Spotify export asks permission to create playlists — just that once, and your normal login is untouched.', page: 'playlists' },
{ title: 'Library Reorganize — Rename only (#875)', desc: 'a lighter reorganize action that just renames your files to your current naming scheme — no re-tagging, no quality/AcoustID re-check, no copy-to-staging. much faster on a NAS, won\'t fail on post-processing, and only touches files whose path actually changes (which also fixes the "2 of 14 previewed but everything got modified" album-splitting). pick it from the new Action dropdown. (thanks @tsoulard / @Tacobell444.)', page: 'library' },
{ title: 'Broader lossless handling (#941, #939)', desc: 'lossy-copy now works for all lossless formats, not just FLAC; and DSD (.dsf/.dff) is recognized as lossless instead of being false-flagged as "truncated".', page: 'downloads' },
{ title: 'Download + search fixes', desc: 'an unbalanced bracket in a filename no longer false-fails as "file not found"; a file we couldn\'t quarantine is left for retry instead of deleted; the Identify search defaults to "artist - title"; "file not found" errors are actionable now; pasted Qobuz/Tidal links inject the exact track into manual search (#932); and the Wing It pool "Fix Match" search works again.', page: 'downloads' },
@ -3443,14 +3443,14 @@ const WHATS_NEW = {
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
title: "Export playlists to Deezer",
description: "send a mirrored playlist back to your Deezer account — the same one-click export, now pointed at Deezer.",
title: "Export playlists to Spotify & Deezer",
description: "send a mirrored playlist back to your streaming account — the same one-click export, now pointed at Spotify and Deezer.",
features: [
"#945 — Sync to Deezer sits next to the ListenBrainz/JSPF options in the export modal; it builds a Deezer playlist in your account",
"#945 — Sync to Spotify / Sync to Deezer sit next to the ListenBrainz/JSPF options in the export modal; each builds a playlist in your account",
"resolves IDs from what's already on hand first — the discovery cache, then your library — so an already-discovered playlist exports instantly with zero API calls",
"re-exporting updates the same playlist in place instead of spawning duplicates",
"an optional \"match missing tracks\" toggle confidently searches Deezer for the stragglers — a wrong-artist or karaoke version is left out, never guessed in",
"Spotify export is wired up but waiting on an on-demand write-permission flow, so it's hidden for now",
"an optional \"match missing tracks\" toggle confidently searches for the stragglers — a wrong-artist or karaoke version is left out, never guessed in",
"the first Spotify export asks permission to create playlists (just that once) — your normal Spotify login is untouched, so upgrading never disrupts anyone",
],
},
{

View file

@ -667,14 +667,18 @@ function exportMirroredPlaylist(playlistId, name) {
<div style="font-weight:600;">Download .jspf file</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Save a JSPF playlist you can upload to ListenBrainz manually.</div>
</button>
<button class="pl-export-choice" data-mode="spotify" style="width:100%;text-align:left;margin-bottom:10px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
<div style="font-weight:600;">Sync to Spotify</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Create a Spotify playlist in your account (the first time, you'll grant permission to create playlists).</div>
</button>
<button class="pl-export-choice" data-mode="deezer" style="width:100%;text-align:left;margin-bottom:16px;padding:13px 15px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:#fff;cursor:pointer;">
<div style="font-weight:600;">Sync to Deezer</div>
<div style="font-size:12px;color:rgba(255,255,255,0.55);">Create a Deezer playlist from this list (uses your Deezer login).</div>
</button>
<div style="font-size:11.5px;color:rgba(255,255,255,0.4);line-height:1.5;">Tracks are matched by ID (MusicBrainz for ListenBrainz/JSPF; the stored Deezer ID for that). Tracks without a match can't be included — you'll see how many made it. Renaming/re-syncing can reset play counts on the destination.</div>
<div style="font-size:11.5px;color:rgba(255,255,255,0.4);line-height:1.5;">Tracks are matched by ID (MusicBrainz for ListenBrainz/JSPF; the stored Spotify/Deezer ID for those). Tracks without a match can't be included — you'll see how many made it. Renaming/re-syncing can reset play counts on the destination.</div>
<label style="display:flex;align-items:flex-start;gap:8px;margin-top:12px;font-size:12px;color:rgba(255,255,255,0.6);cursor:pointer;">
<input type="checkbox" id="pl-export-backfill" style="margin-top:2px;flex-shrink:0;accent-color:rgb(var(--accent-rgb));">
<span><b style="color:rgba(255,255,255,0.75);">Match missing tracks</b> (Deezer) search Deezer for tracks with no known ID. Slower, and only confident matches are added.</span>
<span><b style="color:rgba(255,255,255,0.75);">Match missing tracks</b> (Spotify/Deezer) search the service for tracks with no known ID. Slower, and only confident matches are added.</span>
</label>
<div style="text-align:right;margin-top:14px;"><button onclick="document.getElementById('pl-export-modal').remove()" style="background:none;border:none;color:rgba(255,255,255,0.5);cursor:pointer;font-size:13px;">Cancel</button></div>
</div>`;
@ -726,8 +730,15 @@ async function _startPlaylistExport(playlistId, mode, name, backfill) {
body: isService ? JSON.stringify({ backfill: !!backfill }) : JSON.stringify({ mode }),
});
const data = await resp.json();
// Spotify export needs a one-time write-permission grant. Open the consent in a new tab
// and tell the user to try again once they've authorized.
if (data.needs_auth && data.auth_url) {
window.open(data.auth_url, '_blank');
_setExportStatus(playlistId, `<span style="color:#f59e0b;">Spotify needs permission to create playlists — approve it in the new tab, then click Export again.</span>`, 15000);
return;
}
if (!data.success || !data.job_id) {
_setExportStatus(playlistId, `<span style="color:#ef4444;">Export failed to start</span>`);
_setExportStatus(playlistId, `<span style="color:#ef4444;">${_esc(data.error || 'Export failed to start')}</span>`);
return;
}
_pollPlaylistExport(data.job_id, playlistId, mode, name);