From f3672c7ab4cc01149f8471617561e4abed67f5c0 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 29 Jun 2026 08:32:19 -0700 Subject: [PATCH] spotify export: on-demand write-auth (restores Sync to Spotify safely, #945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- RELEASE_2.8.1_discord.md | 2 +- core/spotify_client.py | 23 ++++++++++++++++ pr_description.md | 10 +++---- tests/test_spotify_client.py | 45 +++++++++++++++++++++++++++++++ web_server.py | 41 ++++++++++++++++++++++++++++ webui/static/helper.js | 12 ++++----- webui/static/stats-automations.js | 17 +++++++++--- 7 files changed, 135 insertions(+), 15 deletions(-) diff --git a/RELEASE_2.8.1_discord.md b/RELEASE_2.8.1_discord.md index b0e60f46..5c80d8af 100644 --- a/RELEASE_2.8.1_discord.md +++ b/RELEASE_2.8.1_discord.md @@ -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) diff --git a/core/spotify_client.py b/core/spotify_client.py index 001e7cef..e5a5ef50 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -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 diff --git a/pr_description.md b/pr_description.md index 517d8fc5..48db76b9 100644 --- a/pr_description.md +++ b/pr_description.md @@ -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. diff --git a/tests/test_spotify_client.py b/tests/test_spotify_client.py index 0716658e..2cbb3e01 100644 --- a/tests/test_spotify_client.py +++ b/tests/test_spotify_client.py @@ -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 diff --git a/web_server.py b/web_server.py index 701e6b44..59ed7d96 100644 --- a/web_server.py +++ b/web_server.py @@ -4940,6 +4940,38 @@ def auth_spotify(): logger.error(f"Error starting Spotify auth: {e}") return f"

Spotify Authentication Error

{str(e)}

", 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 "

Spotify not configured

Add your Spotify app credentials in Settings first.

", 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"

Spotify Export Authorization Error

{str(e)}

", 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() diff --git a/webui/static/helper.js b/webui/static/helper.js index 0ec50e2b..e82246c4 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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", ], }, { diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index f251d918..6f2a5c9c 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -667,14 +667,18 @@ function exportMirroredPlaylist(playlistId, name) {
Download .jspf file
Save a JSPF playlist you can upload to ListenBrainz manually.
+ -
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.
+
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.
`; @@ -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, `Spotify needs permission to create playlists — approve it in the new tab, then click Export again.`, 15000); + return; + } if (!data.success || !data.job_id) { - _setExportStatus(playlistId, `Export failed to start`); + _setExportStatus(playlistId, `${_esc(data.error || 'Export failed to start')}`); return; } _pollPlaylistExport(data.job_id, playlistId, mode, name);