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:
parent
6f451a34e1
commit
f3672c7ab4
7 changed files with 135 additions and 15 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue