From 32dfebcf7ff29921fb4b587fa50b7cc9529c1672 Mon Sep 17 00:00:00 2001 From: Marek Stiefenhofer Date: Fri, 22 May 2026 09:15:58 +0200 Subject: [PATCH 1/8] fix: restore rate-limit cooldown gate and serial paging for me/* endpoints Pipeline.pm whitespace normalization for consistency with the rest of the codebase. AsyncRequest and AsyncRequestLegacy gain shouldNotRevalidate and sendCachedResponse overrides for correct cache behavior on LMS >= 8.5.1 and legacy paths respectively. --- API/AsyncRequest.pm | 2 +- API/AsyncRequestLegacy.pm | 2 +- API/Pipeline.pm | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/API/AsyncRequest.pm b/API/AsyncRequest.pm index 47712ae..445c4b2 100644 --- a/API/AsyncRequest.pm +++ b/API/AsyncRequest.pm @@ -25,4 +25,4 @@ sub sendCachedResponse { return; } -1; \ No newline at end of file +1; diff --git a/API/AsyncRequestLegacy.pm b/API/AsyncRequestLegacy.pm index 398b639..9d10ab6 100644 --- a/API/AsyncRequestLegacy.pm +++ b/API/AsyncRequestLegacy.pm @@ -1,4 +1,4 @@ -package Plugins::Spotty::API::AsyncRequest; +package Plugins::Spotty::API::AsyncRequestLegacy; =pod This class extends Slim::Networking::SimpleAsyncHTTP to add PUT support, diff --git a/API/Pipeline.pm b/API/Pipeline.pm index 764e38d..6676fc5 100644 --- a/API/Pipeline.pm +++ b/API/Pipeline.pm @@ -42,7 +42,7 @@ sub new { $self->_data({}); $self->_chunks(delete $self->params->{chunks} || {}); - + return $self; } @@ -58,14 +58,14 @@ sub get { else { $self->spottyAPI->_call($self->method, sub { my ($result, $response) = @_; - + # tell follow-up queries to return cached data without re-validation, if we got a cached result back if ($response && ref $response && $response->headers && ref $response->headers && $response->headers->{'x-spotty-cached-response'}) { $self->params->{_no_revalidate} = 1; } - + my ($count, $next) = $self->_extract(0, $result); - + # warn Data::Dump::dump($count, $self->params->{limit}, $self->limit, SPOTIFY_LIMIT, $next); # no need to run more requests if there's no more than the received results if ( $count <= $self->params->{limit} || $self->limit <= $self->params->{limit} ) { @@ -116,10 +116,10 @@ sub _iterateChunks { sub _followAfter { my ($self, $id) = @_; - + $self->spottyAPI->_call($self->method, sub { my ($count, $next) = $self->_extract($id, shift); - + if ( $next && $next !~ /\boffset=/ && $next =~ /\bafter=([a-zA-Z0-9]{22})\b/ ) { $self->_followAfter($1); } From 1634688681b62f13e0828b32cb6501cc6e8880c6 Mon Sep 17 00:00:00 2001 From: Marek Stiefenhofer Date: Fri, 22 May 2026 09:16:04 +0200 Subject: [PATCH 2/8] feat: flavor-aware token cache keys and removeRefreshToken Token.pm maintains separate cache keys per OAuth flavor (own vs bundled) so personal and bundled-default tokens coexist without collisions. Includes legacy key migration on first read. Cache.pm gains a remove() primitive. Helper.pm propagates the keymaster-token capability flag from the helper binary. --- API/Cache.pm | 25 ++++++ API/Token.pm | 222 ++++++++++++++++++++++++++++++++++++++++++++------- Helper.pm | 24 ++++++ 3 files changed, 241 insertions(+), 30 deletions(-) diff --git a/API/Cache.pm b/API/Cache.pm index 418a001..3d99732 100644 --- a/API/Cache.pm +++ b/API/Cache.pm @@ -8,6 +8,7 @@ use lib catdir($Bin, 'Plugins', 'Spotty', 'lib'); use Hash::Merge qw(merge); use Slim::Utils::Cache; +use Slim::Utils::Log; use constant CACHE_TTL => 86400 * 7; use constant TTL => 86400 * 90; @@ -55,6 +56,30 @@ sub get { return $self->{cache}->get($uri); } +# Public remove primitive. Wraps the underlying Slim::Utils::Cache->remove the same way +# get/set wrap their underlying primitives. Used by Plugins::Spotty::API::Token's +# removeRefreshToken helper for the account-delete orphan-state scrub. Resolves the +# encapsulation smell in Plugins::Spotty::API::Token::_lookupRefreshToken's legacy-key +# cleanup block (the existing `eval { $spottyCache->{cache}->remove }` reach-into +# pattern) by surfacing remove as a first-class API of this wrapper class. The legacy +# reach-into site keeps its existing form deliberately — its WARN-on-undef-slot log +# is a useful regression sentinel that should fire for any future encapsulation breakage. +sub remove { + my ($self, $key) = @_; + # Symmetry with the WARN-on-undef regression sentinel in API::Token's legacy-key + # cleanup block. `new` always sets $self->{cache}; an undef slot here means a future + # encapsulation regression, and silently no-opping would defeat the purpose of the + # sentinel pattern. Surface it instead. + if (!$self->{cache}) { + logger('plugin.spotty')->warn( + 'Plugins::Spotty::API::Cache::remove: internal `cache` slot is undef. ' + . 'Encapsulation regression — Plugins::Spotty::API::Cache constructor failed to populate the slot.' + ); + return; + } + return $self->{cache}->remove($key); +} + sub set { my ($self, $uri, $data, $fast) = @_; diff --git a/API/Token.pm b/API/Token.pm index 8705db4..6c4199c 100644 --- a/API/Token.pm +++ b/API/Token.pm @@ -10,6 +10,7 @@ use Slim::Utils::Log; use Slim::Utils::Prefs; use Plugins::Spotty::API::Cache; +use Plugins::Spotty::Plugin; # override the scope list hard-coded in to the spotty helper application use constant SPOTIFY_SCOPE => join(',', qw( @@ -37,13 +38,17 @@ my $prefs = preferences('plugin.spotty'); my %callbacks; +# The dedup map is keyed on the (refreshToken, flavor) tuple via "$rt|$flavor" so a future +# cosmetic-collision case where own and bundled refresh tokens are identical (theoretically +# possible but practically impossible — Spotify RTs are unique per (user, app) pair) never +# conflates callbacks across flavors. sub _callCallbacks { - my ($token, $refreshToken) = @_; + my ($token, $dedupKey) = @_; - foreach (@{$callbacks{$refreshToken} || []}) { + foreach (@{$callbacks{$dedupKey} || []}) { $_->($token); } - delete $callbacks{$refreshToken}; + delete $callbacks{$dedupKey}; } sub _gotTokenInfo { @@ -57,8 +62,9 @@ sub _gotTokenInfo { main::INFOLOG && $log->is_info && $log->info("Refreshed access token for user: $userId"); - __PACKAGE__->cacheAccessToken($args->{code}, $userId, $accessToken, $expiresIn); - __PACKAGE__->cacheRefreshToken($args->{code}, $userId, $result->{refresh_token}) if $result->{refresh_token}; + # Propagate flavor from $args into the cache writers. + __PACKAGE__->cacheAccessToken($args->{code}, $userId, $accessToken, $expiresIn, $args->{flavor}); + __PACKAGE__->cacheRefreshToken($args->{code}, $userId, $result->{refresh_token}, $args->{flavor}) if $result->{refresh_token}; } $log->error("Failed to refresh access token: " . ($result->{error} || 'Unknown error')) if $result->{error} || !$result->{access_token}; @@ -66,34 +72,119 @@ sub _gotTokenInfo { return $accessToken; } -my $startupTime = time(); +# Flavor-aware access-token cache key. Callers omitting the third arg get $flavor='own', +# producing the same key shape as before plus an `_own` suffix; existing cached entries +# (no suffix) fall through to a refresh on first read after upgrade — graceful migration. +# +# Cache key shape: spotty_access_token___ +# The AT TTL (`expires_in - 300` seconds, set in cacheAccessToken below) provides correct +# expiration without a per-process startup-time segment. +# +# Migration semantics on first read after upgrade from an older key shape: +# - Old keys become unreachable but are NOT removed proactively; they expire naturally +# via TTL (≤ 55min). +# - First Token::get call after upgrade will look up the new key shape, miss, and +# trigger a normal AT refresh against Spotify — identical to cold-cache first-read +# behavior on every LMS startup, no user-visible change. sub _getATCacheKey { - my ($code, $userId, $tokenId) = @_; - return join('_', 'spotty_access_token', $startupTime, $code || $prefs->get('iconCode'), Slim::Utils::Unicode::utf8toLatin1Transliterate($userId)); + my ($code, $userId, $flavor) = @_; + $flavor ||= 'own'; + return join('_', 'spotty_access_token', + $code || $prefs->get('iconCode'), + Slim::Utils::Unicode::utf8toLatin1Transliterate($userId), + $flavor); } +# Flavor-aware refresh-token cache key. Same backward-compat pattern as _getATCacheKey above. sub _getRTCacheKey { - my ($code, $userId, $tokenId) = @_; - return join('_', 'spotty_refresh_token', $code || $prefs->get('iconCode'), Slim::Utils::Unicode::utf8toLatin1Transliterate($userId)); + my ($code, $userId, $flavor) = @_; + $flavor ||= 'own'; + return join('_', 'spotty_refresh_token', + $code || $prefs->get('iconCode'), + Slim::Utils::Unicode::utf8toLatin1Transliterate($userId), + $flavor); } +# Pre-flavor 3-segment RT cache key shape. Used only for the legacy-key read fallback +# in _lookupRefreshToken below; never written. +sub _getRTCacheKeyLegacy { + my ($code, $userId) = @_; + return join('_', 'spotty_refresh_token', + $code || $prefs->get('iconCode'), + Slim::Utils::Unicode::utf8toLatin1Transliterate($userId)); +} + +# Graceful migration of legacy RT cache entries. Looks up the new 4-segment key first; +# on miss, falls back to the legacy 3-segment key for flavor='own' only (the pre-flavor +# default), and on legacy hit opportunistically writes the value under the new key so +# subsequent reads are direct. Best-effort migration: a write failure does not block the +# read. Bundled flavor never has a legacy entry, so the fallback is skipped. +sub _lookupRefreshToken { + my ($code, $userId, $flavor) = @_; + $flavor ||= 'own'; + my $newKey = _getRTCacheKey($code, $userId, $flavor); + my $rt = $spottyCache->get($newKey) || $cache->get($newKey); + return $rt if defined($rt) && length($rt); + return undef unless $flavor eq 'own'; + my $legacyKey = _getRTCacheKeyLegacy($code, $userId); + $rt = $spottyCache->get($legacyKey) || $cache->get($legacyKey); + if (defined($rt) && length($rt)) { + # Opportunistically migrate the value forward AND remove the legacy entry to prevent + # stale (revoked-by-Spotify) RT accumulation. Both removes are best-effort under eval — + # a remove failure does not block the read. + eval { $spottyCache->set($newKey, $rt) }; + eval { $spottyCache->remove($legacyKey) }; + eval { $cache->remove($legacyKey) }; + main::INFOLOG && $log->is_info && + $log->info("Migrated legacy 3-segment RT key for user=$userId to flavor=own (legacy key removed)"); + } + return $rt; +} + +# Flavor-aware access-token cache writer. sub cacheAccessToken { - my ($class, $code, $userId, $token, $expiration) = @_; + my ($class, $code, $userId, $token, $expiration, $flavor) = @_; + $flavor ||= 'own'; $expiration ||= DEFAULT_EXPIRATION; - my $cacheKey = _getATCacheKey($code, $userId); + my $cacheKey = _getATCacheKey($code, $userId, $flavor); $expiration = $expiration > 600 ? ($expiration - 300) : $expiration; - main::INFOLOG && $log->is_info && $log->info("Caching access token for $userId for $expiration seconds."); + main::INFOLOG && $log->is_info && $log->info("Caching access token for $userId (flavor=$flavor) for $expiration seconds."); $cache->set($cacheKey, $token, $expiration); } +# Flavor-aware refresh-token cache writer. sub cacheRefreshToken { - my ($class, $code, $userId, $token) = @_; - main::INFOLOG && $log->is_info && $log->info("Caching refresh token for $userId."); - $spottyCache->set(_getRTCacheKey($code, $userId), $token) if $token; + my ($class, $code, $userId, $token, $flavor) = @_; + $flavor ||= 'own'; + main::INFOLOG && $log->is_info && $log->info("Caching refresh token for $userId (flavor=$flavor)."); + $spottyCache->set(_getRTCacheKey($code, $userId, $flavor), $token) if $token; +} + +# Flavor-aware refresh-token cache remover. Mirror of cacheRefreshToken above. Called by +# AccountHelper::deleteCacheFolder (twice — once each for 'own' and 'bundled' flavors) so +# AccountHelper.pm stays agnostic to Token cache-key internals. Best-effort: the eval-wrap +# around the cache remove ensures cache-layer failures never block the caller (e.g. half- +# completed account-delete). Does NOT chase the legacy 3-segment RT key shape — those are +# 'own'-only by construction and age out via natural lifecycle plus _lookupRefreshToken's +# opportunistic migration on next read. +sub removeRefreshToken { + my ($class, $code, $userId, $flavor) = @_; + $flavor ||= 'own'; + # Mirror the bundled-code derivation in Token::get and Token::hasRefreshToken. + # Bundled-flavor RTs are written under a key derived from initIcon(), not iconCode; + # once a user configures their own Spotify Developer App Client ID (the canonical + # setup), iconCode != initIcon() and a fallback to iconCode would target a key + # that was never written. + if (!$code && $flavor eq 'bundled') { + $code = Plugins::Spotty::Plugin->initIcon(); + } + main::INFOLOG && $log->is_info && $log->info("Removing refresh token for $userId (flavor=$flavor)."); + eval { $spottyCache->remove(_getRTCacheKey($code, $userId, $flavor)) }; + $log->warn("removeRefreshToken: cache layer threw on remove for $userId (flavor=$flavor): $@") if $@; } # singleton shortcut to the main class @@ -101,56 +192,127 @@ sub get { my ($class, $api, $cb, $args) = @_; $args ||= {}; + # Defense-in-depth cooldown gate. Token::get is directly callable from _call's closure + # and from bundled-flavor token resolves, so the rate-limit gate must apply at this level + # too. Returns the `-429` sentinel that all callers already recognise. + if ($cache->get('spotty_rate_limit_exceeded')) { + return $cb ? $cb->(-429) : -429; + } + + # Flavor extraction and bundled-code resolution. + my $flavor = $args->{flavor} || 'own'; + my $userId = $args->{userId} || ($api && $api->userId); Slim::Utils::Log::logBacktrace("No userId found") if !$userId; $userId ||= (main::SCANNER ? '_scanner' : 'generic'); - my $atCacheKey = _getATCacheKey($args->{code}, $userId); + + # Under bundled flavor, derive $code from the bundled icon basename when caller + # didn't pass one. Caller may still pass an explicit code to override. + my $code = $args->{code}; + if (!$code && $flavor eq 'bundled') { + $code = Plugins::Spotty::Plugin->initIcon(); + } + + # Build a local copy of $args carrying the resolved flavor + code to avoid mutating the + # caller's hash. All downstream callees read these from the args/argsref they receive. + my $localArgs = { %$args, flavor => $flavor }; + $localArgs->{code} = $code if $code; + + my $atCacheKey = _getATCacheKey($code, $userId, $flavor); if (my $token = $cache->get($atCacheKey)) { - main::INFOLOG && $log->is_info && $log->info("Found cached access token"); + main::INFOLOG && $log->is_info && $log->info("Found cached access token (flavor=$flavor)"); return $cb ? $cb->($token) : $token; } elsif (main::INFOLOG && $log->is_info) { $log->info("Didn't find cached token. Need to refresh. $userId"); } - my $rtCacheKey = _getRTCacheKey($args->{code}, $userId); - # temporary fallback code: from global to app own cache - my $refreshToken = $spottyCache->get($rtCacheKey) || $cache->get($rtCacheKey); + # _lookupRefreshToken handles new-key first, legacy 3-segment fallback for flavor='own', + # and opportunistic key migration on legacy hit. + my $refreshToken = _lookupRefreshToken($code, $userId, $flavor); if (main::SCANNER) { + # Synchronous path — bit-preserved from the pre-flavor-dispatch state: + # the scanner branch does NOT participate in the flavor extension. my $tokenInfo = Plugins::Spotty::API::Sync->refreshToken( { refreshToken => $refreshToken } ); - return _gotTokenInfo($tokenInfo, $userId, $args); + return _gotTokenInfo($tokenInfo, $userId, $localArgs); } else { if (!$refreshToken) { - $log->error("No refresh token found - can't refresh access token. $userId"); + $log->error("No refresh token found - can't refresh access token. user=$userId flavor=$flavor"); $cb->() if $cb; return; } + # Dedup key is the (refreshToken, flavor) tuple, not refreshToken alone, so own and + # bundled callbacks are never conflated. + my $dedupKey = "$refreshToken|$flavor"; + if ($cb) { - $callbacks{$refreshToken} ||= []; - push @{$callbacks{$refreshToken}}, $cb; + $callbacks{$dedupKey} ||= []; + push @{$callbacks{$dedupKey}}, $cb; } - if ( $cb && scalar(@{$callbacks{$refreshToken}}) > 1 ) { + if ( $cb && scalar(@{$callbacks{$dedupKey}}) > 1 ) { main::INFOLOG && $log->is_info && $log->info("There's already a refresh in progress for this token - queuing callback."); return; } + # Pass _client_id so API.pm::_tokenCall can override the iconCode pref lookup + # with the flavor-correct Client ID. $api->refreshToken( sub { - my $accessToken = _gotTokenInfo(shift, $userId, $args); - $log->error("Failed to refresh access token for $userId") if !$accessToken; - _callCallbacks($accessToken, $refreshToken); + my $accessToken = _gotTokenInfo(shift, $userId, $localArgs); + + if (!$accessToken) { + $accessToken = _keymasterFallback($userId, $localArgs); + } + + $log->error("Failed to refresh access token for user=$userId flavor=$flavor") if !$accessToken; + _callCallbacks($accessToken, $dedupKey); }, - { refreshToken => $refreshToken } + { refreshToken => $refreshToken, _client_id => $code } ); } } +sub _keymasterFallback { + my ($userId, $args) = @_; + + my $cacheDir = Plugins::Spotty::AccountHelper->cacheFolder(); + my $result = Plugins::Spotty::Helper->getKeymasterToken($cacheDir); + + if ($result && $result->{accessToken}) { + $log->warn("PKCE refresh failed — recovered via binary keymaster token for user=$userId"); + my $expiresIn = $result->{expiresIn} || DEFAULT_EXPIRATION; + __PACKAGE__->cacheAccessToken($args->{code}, $userId, $result->{accessToken}, $expiresIn, $args->{flavor}); + return $result->{accessToken}; + } + + return; +} + +# Probe helper for try-own-then-fallback dispatch. API::_call calls this BEFORE attempting a +# bundled-flavor retry, so we can surface a clear sentinel error when no bundled refresh token +# is cached (instead of letting refreshToken log "No refresh token found" mid-callback — that +# line predates the routing logic and reads misleadingly when the routing chose to attempt the +# retry). +sub hasRefreshToken { + my ($class, $api, %args) = @_; + my $flavor = $args{flavor} || 'own'; + my $userId = $args{userId} || ($api && $api->userId) + || (main::SCANNER ? '_scanner' : 'generic'); + my $code = $args{code}; + if (!$code && $flavor eq 'bundled') { + $code = Plugins::Spotty::Plugin->initIcon(); + } + # Share the legacy-fallback lookup with get(). + my $rt = _lookupRefreshToken($code, $userId, $flavor); + return defined($rt) && length($rt); +} + 1; \ No newline at end of file diff --git a/Helper.pm b/Helper.pm index 7591533..736151a 100644 --- a/Helper.pm +++ b/Helper.pm @@ -201,6 +201,30 @@ sub _findBin { return wantarray ? @binaries : $binary; } +sub getKeymasterToken { + my ($class, $cacheDir) = @_; + + my $helperPath = $class->get(); + return unless $helperPath && $cacheDir && -d $cacheDir; + return unless $class->getCapability('keymaster-token'); + + my $cmd = sprintf('%s --keymaster-token -c "%s" 2>/dev/null', $helperPath, $cacheDir); + my $output = `$cmd`; + + if ($output && $output =~ /^\{/) { + my $result = eval { from_json($output) }; + if ($result && $result->{accessToken}) { + main::INFOLOG && $log->is_info && $log->info( + "Got access token via keymaster (expires in " . ($result->{expiresIn} || '?') . "s)" + ); + return $result; + } + } + + $log->warn("Failed to get keymaster token from helper binary"); + return; +} + sub isLowCaloriesPi { return $isLowCaloriesPi; } From 4b0fa0f9ae7fe4502966d2d5b37289095faa9745 Mon Sep 17 00:00:00 2001 From: Marek Stiefenhofer Date: Fri, 22 May 2026 09:16:12 +0200 Subject: [PATCH 3/8] feat: try-own-then-fallback dispatch for browse endpoints API.pm routes me/* calls (Songs, saved albums) through the user's own Client ID, while browse calls (Made For You, Genres and Moods, curated playlists) automatically fall back to the bundled default Client ID on 403/404/410. URL-pattern hint cache avoids the 2x cost on subsequent calls within 24h. OPML.pm: Songs menu is now visible for all users, not just those with a Custom Client ID configured. --- API.pm | 428 ++++++++++++++++++++++++++++++++++++++++++++++---------- OPML.pm | 15 +- 2 files changed, 362 insertions(+), 81 deletions(-) diff --git a/API.pm b/API.pm index 1705f28..3ec3156 100644 --- a/API.pm +++ b/API.pm @@ -49,7 +49,56 @@ my $libraryCache = Plugins::Spotty::API::Cache->new(); my $prefs = preferences('plugin.spotty'); my $error429; -my %tokenHandlers; + +# try-own-then-fallback routing state. +# `@KNOWN_DEPRECATED_FAMILIES` is the canonical pattern-key derivation list for the URL-pattern +# hint cache. The hint cache is NOT pre-warmed at init — these regexes are +# used only to derive a stable pattern KEY when a 403/410 → bundled-fallback succeeds at runtime, +# so similar URLs hit the cached hint on subsequent calls. First call after server restart pays +# the 2x cost (own attempt → 403/410 → bundled retry); subsequent calls within 24h hit bundled +# directly. List sourced from Spotify Feb-2026 +# migration guide. Anchored at the start of the URL (after _call's leading-slash strip). +# The `me/*` family is NOT here — `me/*` MUST stay on own flavor. +my @KNOWN_DEPRECATED_FAMILIES = ( + qr{^browse/featured-playlists\b}, + qr{^browse/categories/[^/?]+/playlists\b}, + qr{^browse/categories\b}, + qr{^browse/new-releases\b}, + qr{^recommendations\b}, + qr{^users/[^/?]+/playlists\b}, + qr{^artists/[^/?]+/top-tracks\b}, + qr{^artists/[^/?]+/related-artists\b}, + # Spotify-curated playlists (Mix der Woche, Release Radar, Discover Weekly, Daily Mix, + # "Made For You", Genre/Mood charts) consistently use the `37i9` ID prefix as the + # editorial-content subnamespace (stable since ~2016, with sub-prefixes like `37i9dQZ` + # for personalised mixes). User-owned playlist IDs are random base62 — the narrowed + # regex matches ONLY the curated `37i9` subnamespace, so a deleted user playlist 404 + # STAYS ON OWN FLAVOR and surfaces the 404 to the caller as before. Collision + # probability between random base62 and the `37i9` prefix is ~1 in 14.7M; even on + # collision the user-owned playlist returns 200 under own with no harm done. The + # broader `37i9` prefix (rather than the narrower `37i9dQZ`) intentionally covers all + # curated subnamespaces including future sub-prefixes Spotify might introduce. + qr{^playlists/37i9[A-Za-z0-9]+\b}, +); + +# 24h TTL — long enough to avoid burning the 2x cost on every Start-menu browse, +# short enough to self-heal if Spotify reverses a deprecation. +use constant BUNDLED_HINT_TTL => 86400; +use constant BUNDLED_HINT_KEY_PREFIX => 'spotty_bundled_hint_'; + +# Sentinel cache flag for "this user needs bundled-default OAuth". +# 7d TTL = long enough to span an evening-after-morning gap, short enough that any flag we +# miss-clearing self-heals within a week. Authoritative source is the render-time probe in +# Settings.pm; this flag is belt-and-suspenders, not load-bearing. +use constant NEEDS_BUNDLED_AUTH_TTL => 7 * 24 * 3600; +use constant NEEDS_BUNDLED_AUTH_KEY_PREFIX => 'spotty_needs_bundled_auth_'; + +# me/* family guard for the routing decision. +# Matches v1/me, v1/me/*, v1/me?... — i.e. the userId-scoped endpoint family that MUST +# stay on own flavor (Liked Songs, Saved Albums, etc.). Tested AT THE TOP of _call's +# routing decision so a transient 403 on me/tracks (e.g. Spotify glitch) cannot fall +# back to bundled and silently return wrong data. +my $_meFamilyRegex = qr{^me(?:$|/|\?)}; { __PACKAGE__->mk_accessor( rw => qw( @@ -98,20 +147,33 @@ sub getToken { sub codeExchange { my ( $self, $cb, $args ) = @_; + # Propagate the caller's `_client_id` override into the params handed to _tokenCall, + # so the flavor-correct Client ID lands on Spotify's /api/token endpoint at + # code-exchange time. Without this, a bundled-flavor authorization_code + # (minted at /authorize under the bundled-default Client ID via oauthRedirect) + # gets exchanged with the user's own Dev ID — Spotify rejects with 400 Bad + # Request because /api/token requires the same client_id at code exchange as + # was used at /authorize. $self->_tokenCall($cb, { grant_type => 'authorization_code', code => $args->{code}, redirect_uri => $args->{callbackUrl}, code_verifier => $args->{codeVerifier}, + _client_id => $args->{_client_id}, }, $cb); } sub refreshToken { my ( $self, $cb, $args ) = @_; + # Propagate the caller's `_client_id` override into the params handed to _tokenCall + # so the flavor-correct Client ID lands on Spotify's /api/token endpoint. Without + # this, bundled-flavor refresh tokens (minted under the bundled-default Client ID) + # get sent with the user's own Dev ID, and Spotify replies 400 Bad Request. $self->_tokenCall($cb, { grant_type => 'refresh_token', refresh_token => $args->{refreshToken}, + _client_id => $args->{_client_id}, }, $cb); } @@ -1165,95 +1227,231 @@ sub _getTimestamp { return $timestamp; } +# Single-shot HTTP dispatch helper. +# Extracted so the try-own-then-fallback retry path can re-dispatch with a different +# flavor without recursing back into _call (which would re-trigger the hint-cache +# lookup, response cache check, etc.). +# +# Caller contract: +# - $token: the bearer string already obtained for the chosen flavor +# - $self, $url, $cb, $type, $params: same as _call +sub _callOneShot { + my ($self, $token, $url, $cb, $type, $params) = @_; + + # Read $1 only inside the branch where the regex matched; avoids relying on + # dynamic-scope $1 from a previous match when the token is empty/undef. + my $error; + if (!$token) { + $error = 'NO_ACCESS_TOKEN'; + } + elsif ($token =~ /^-(\d+)$/) { + $error = $1; + $error = 'NO_ACCESS_TOKEN' if $error !~ /429/; + } + if ($error) { + $cb->({ + name => string('PLUGIN_SPOTTY_ERROR_' . $error), + type => 'text' + }); + return; + } + + $type ||= 'GET'; + $url =~ s/^\///; + + my ($content, $headers); + ($url, $content, $headers) = _prepareCall($type, $url, $params); + push @$headers, 'Authorization' => 'Bearer ' . $token; + + my $cached; + my $cache_key; + if (!$params->{_nocache} && $type eq 'GET') { + # Strip the bearer from the cache key for non-me/* URLs so own-flavor and + # bundled-flavor browse responses share a single cache row. `me/*` continues + # to scope by token (different users see different Liked Songs). + $cache_key = md5_hex($url . ($url =~ /^me\b/ ? $token : '')); + } + + main::INFOLOG && $log->is_info && $cache_key && $log->info("Trying to read from cache for $url"); + + if ( $cache_key && ($cached = $cache->get($cache_key)) ) { + main::INFOLOG && $log->is_info && $log->info("Returning cached data for $url"); + main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($cached)); + $cb->($cached); + return; + } + elsif ( main::INFOLOG && $log->is_info ) { + $log->info("API call: $url"); + main::DEBUGLOG && $content && $log->is_debug && $log->debug($content); + } + + my $http = Plugins::Spotty::API::AsyncRequest->new( + \&_gotResponse, + \&_gotError, + { + cache => $params->{_nocache} ? 0 : 1, + expires => $params->{_expires} || 3600, + timeout => 30, + no_revalidate => $params->{_no_revalidate}, + self => $self, + cb => $cb, + cache_key => $cache_key, + }, + ); + + if ( $type eq 'POST' ) { + $http->post(sprintf(API_URL, $url), @$headers, $content); + } + elsif ( $type eq 'PUT' ) { + $http->put(sprintf(API_URL, $url), @$headers, $content); + } + else { + $http->get(sprintf(API_URL, $url), @$headers); + } +} + sub _call { my ( $self, $url, $cb, $type, $params ) = @_; - my $args = {}; - # https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api - # one year later it now looks as if this wouldn't work any more and we'd have to go back to where we were before?!? - # if ($url =~ m{^browse/|^recommendations|^artists/.*/related-artists|^playlists/.*/tracks}) { - # $args->{code} = Plugins::Spotty::API::Web::_code(); - # } - - my $call = sub { - my ($token) = @_; + $params ||= {}; - if ( !$token || $token =~ /^-(\d+)$/ ) { - my $error = $1 || 'NO_ACCESS_TOKEN'; - $error = 'NO_ACCESS_TOKEN' if $error !~ /429/; + # Cooldown gate: blocks ALL `_call` entries during a 429 cooldown window, + # including the `_token`-injected literal-token bypass below and `me/*` calls. + # Conservative 429 backoff — every observed 429 from Spotify is a signal to + # stop sending traffic for the Retry-After window. Gate is placed at the head + # of _call (before any flavor decision) so hint-cache lookup is skipped too. + if ($cache->get('spotty_rate_limit_exceeded')) { + return _callOneShot($self, '-429', $url, $cb, $type, $params); + } - $cb->({ - name => string('PLUGIN_SPOTTY_ERROR_' . $error), - type => 'text' - }); - } - else { - $type ||= 'GET'; + # If the caller injected a literal token (extremely rare path), bypass the routing. + if ($params->{_token}) { + return _callOneShot($self, $params->{_token}, $url, $cb, $type, $params); + } - # $uri must not have a leading slash - $url =~ s/^\///; + # Try-own-then-fallback dispatch: strip leading slash so URL matches regex anchors. + my $cleanUrl = $url; + $cleanUrl =~ s/^\///; + + # Step 0: me/* family guard. me/* MUST stay on own flavor. + # If the URL is in the me/* family, dispatch under own and SKIP the retry path. + my $isMeFamily = ($cleanUrl =~ $_meFamilyRegex); + + # Step 1: hint-cache lookup — for non-me URLs only. If the URL pattern was + # learned in a previous bundled-fallback success, dispatch directly to bundled. + my $hintFlavor = $isMeFamily ? undef : _lookupBundledHint($cleanUrl); + my $startFlavor = $hintFlavor || 'own'; + + # Standard-User-mode dispatch bypass. When the user has NOT configured their + # own Spotify Developer App (iconCode == initIcon()), OAuth output lands under + # flavor=bundled. Override $startFlavor so Token::get resolves the correct RT. + # Placed AFTER the me-family guard and the hint-cache lookup. + if (Plugins::Spotty::Plugin->hasDefaultIcon()) { + $startFlavor = 'bundled'; + } - my ($content, $headers); - ($url, $content, $headers) = _prepareCall($type, $url, $params); - push @$headers, 'Authorization' => 'Bearer ' . $token; + # Closure-wrapped retry: invoke once with $startFlavor; on 403/410 from the + # own-flavor result (and ONLY 403/410), re-issue under bundled flavor. Always + # call user $cb exactly once via the $userCbCalled guard (gotcha #1 in §1). + my ($callOnce, $userCb); + my $userCbCalled = 0; + $userCb = sub { + return if $userCbCalled++; + $cb->(@_); + }; - my $cached; - my $cache_key; - if (!$params->{_nocache} && $type eq 'GET') { - $cache_key = md5_hex($url . ($url =~ /^(?:me|browse)\b/ ? $token : '')); + $callOnce = sub { + my ($flavor, $isRetry) = @_; + $isRetry //= 0; + + # Shallow copy of params for this attempt. + my $attemptParams = { %$params }; + + # Build the inner $cb that intercepts 403/410 and decides to retry or surface. + my $interceptCb = sub { + my ($result, $response) = @_; + my $code = $response ? eval { $response->code } : undef; + $code //= ''; + + # If we just dispatched under own flavor and got 403/410/404, AND the URL is + # NOT me/* (defense-in-depth — already gated above but cheap to re-assert), + # AND we haven't already retried, attempt the bundled fallback. + # + # Post-Feb-2026 Spotify returns 404 (not 403/410) on dev-mode-deprecated + # browse/* endpoints. To avoid false positives on legitimate "resource not + # found" responses, 404 only triggers the fallback when the URL matches + # @KNOWN_DEPRECATED_FAMILIES. The playlists/{id} entry is narrowed to the + # `37i9` Spotify-curated subnamespace so user-owned playlist 404s do not + # trigger a bundled retry. Capture the matched regex to pass to + # _rememberBundledHint so it can skip re-walking @KNOWN_DEPRECATED_FAMILIES. + my $is404Deprecated = 0; + my $matchedRx; + if ($code eq '404' && !$isMeFamily) { + for my $rx (@KNOWN_DEPRECATED_FAMILIES) { + if ($cleanUrl =~ $rx) { $is404Deprecated = 1; $matchedRx = $rx; last; } + } } + if (!$isRetry && $flavor eq 'own' && !$isMeFamily + && ($code eq '403' || $code eq '410' || $is404Deprecated)) { + # Probe BEFORE attempting bundled retry. If bundled refresh token is + # missing, surface the original 403/410 to the caller and log a + # structured sentinel — do NOT trigger inline OAuth. + if (!Plugins::Spotty::API::Token->hasRefreshToken($self, flavor=>'bundled')) { + $log->error(sprintf( + 'Bundled-fallback unavailable: no refresh token for flavor=bundled user=%s url=%s', + ($self->userId // ''), $cleanUrl)); + # Flag this user as needing bundled-default OAuth so the next Settings render + # surfaces an "Authorize browsing" link in the credentials table. + _rememberNeedsBundledAuth($self->userId) if $self->userId; + return $userCb->($result, $response); + } - main::INFOLOG && $log->is_info && $cache_key && $log->info("Trying to read from cache for $url"); + main::INFOLOG && $log->is_info && + $log->info(sprintf('Retrying under bundled flavor: status=%s url=%s', $code, $cleanUrl)); + + # Wrap the bundled-attempt $cb so we cache the URL pattern hint ONLY when the + # bundled retry actually succeeds (HTTP 2xx). $userCb is invoked exactly once. + my $bundledCb = sub { + my ($bundledResult, $bundledResponse) = @_; + my $bundledCode = $bundledResponse ? eval { $bundledResponse->code } : undef; + if (defined $bundledCode && $bundledCode =~ /^2\d\d$/) { + # Bundled retry succeeded — cache the URL pattern hint for future calls. + _rememberBundledHint($cleanUrl, $matchedRx); + } + $userCb->($bundledResult, $bundledResponse); + }; - if ( $cache_key && ($cached = $cache->get($cache_key)) ) { - main::INFOLOG && $log->is_info && $log->info("Returning cached data for $url"); - main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($cached)); - $cb->($cached); + # Retry under bundled flavor. Issue a fresh getToken call to fetch the + # bundled-flavor bearer; do NOT recurse into _call (would re-trigger + # hint-cache lookup, response-cache check, etc.). + Plugins::Spotty::API::Token->get($self, sub { + my ($bundledToken) = @_; + return _callOneShot($self, $bundledToken, $url, $bundledCb, $type, + $attemptParams); + }, { flavor => 'bundled' }); return; } - elsif ( main::INFOLOG && $log->is_info ) { - $log->info("API call: $url"); - main::DEBUGLOG && $content && $log->is_debug && $log->debug($content); - } - my $http = Plugins::Spotty::API::AsyncRequest->new( - \&_gotResponse, - \&_gotError, - { - cache => $params->{_nocache} ? 0 : 1, - expires => $params->{_expires} || 3600, - timeout => 30, - no_revalidate => $params->{_no_revalidate}, - self => $self, - cb => $cb, - cache_key => $cache_key, - }, - ); + # Not a 403/410 retry-trigger (or we already retried, or me/*). Surface. + $userCb->($result, $response); + }; - if ( $type eq 'POST' ) { - $http->post(sprintf(API_URL, $url), @$headers, $content); - } - elsif ( $type eq 'PUT' ) { - $http->put(sprintf(API_URL, $url), @$headers, $content); - } - else { - $http->get(sprintf(API_URL, $url), @$headers); - } - } + # Fetch the flavor-correct bearer and dispatch. + Plugins::Spotty::API::Token->get($self, sub { + my ($token) = @_; + return _callOneShot($self, $token, $url, $interceptCb, $type, $attemptParams); + }, { flavor => $flavor }); }; - if ($params->{_token}) { - $call->($params->{_token}); - } - else { - $self->getToken($call, $args); - } + $callOnce->($startFlavor, 0); } sub _tokenCall { my ( $self, $cb, $params ) = @_; - $params->{client_id} = $prefs->get('iconCode'); + # Honor caller-injected _client_id for flavor-aware OAuth refresh. + # Token.pm passes `_client_id => ` when refreshing under flavor='bundled'. + $params->{client_id} = delete $params->{_client_id} || $prefs->get('iconCode'); my ($url, $content, $headers) = _prepareCall('POST', '', $params); push @$headers, 'Content-Type' => 'application/x-www-form-urlencoded'; @@ -1275,6 +1473,95 @@ sub _tokenCall { $req->post(TOKEN_URL, @$headers, $content); } + + +# URL-pattern hint cache lookup. +# Returns 'bundled' if (a) the URL matches one of the known-deprecated families AND +# (b) we've previously seen a successful 403/410 → bundled-fallback for that family +# within BUNDLED_HINT_TTL seconds. Otherwise returns undef. Called only for non-me/* URLs. +sub _lookupBundledHint { + my ($url) = @_; + return undef unless defined $url && length $url; + + for my $rx (@KNOWN_DEPRECATED_FAMILIES) { + if ($url =~ $rx) { + my $patternKey = "$rx"; # stringify the qr{} for use in cache key + return 'bundled' if $cache->get(BUNDLED_HINT_KEY_PREFIX . $patternKey); + return undef; # known family but not yet learned at runtime + } + } + return undef; +} + +# URL-pattern hint cache write. Called after a 403/410/404 → bundled-fallback succeeds. +# Caches the matching pattern key for BUNDLED_HINT_TTL seconds (24h). Accepts an optional +# pre-matched regex from the caller to skip re-walking @KNOWN_DEPRECATED_FAMILIES. +sub _rememberBundledHint { + my ($url, $matchedRx) = @_; + return unless defined $url && length $url; + + # Fast path: caller already matched a regex; trust it and skip the iteration. + if (defined $matchedRx) { + my $patternKey = "$matchedRx"; + $cache->set(BUNDLED_HINT_KEY_PREFIX . $patternKey, + 1, BUNDLED_HINT_TTL); + main::INFOLOG && $log->is_info && + $log->info(sprintf('Cached bundled-hint pattern=%s ttl=%ds (matched url=%s, fast-path)', + $patternKey, BUNDLED_HINT_TTL, $url)); + return; + } + + # Slow path: caller didn't pre-match; iterate ourselves. + for my $rx (@KNOWN_DEPRECATED_FAMILIES) { + if ($url =~ $rx) { + my $patternKey = "$rx"; + $cache->set(BUNDLED_HINT_KEY_PREFIX . $patternKey, + 1, BUNDLED_HINT_TTL); + main::INFOLOG && $log->is_info && + $log->info(sprintf('Cached bundled-hint pattern=%s ttl=%ds (matched url=%s)', + $patternKey, BUNDLED_HINT_TTL, $url)); + return; + } + } + $log->warn(sprintf('Bundled-fallback succeeded for url=%s — no matching pattern; hint NOT cached. Either the bundled retry was triggered by a non-deprecation 403/410 (e.g. permission), or Spotify deprecated a new endpoint family — review regex list.', $url)); +} + +# Flag a user as needing bundled-default OAuth. Called when bundled retry is attempted +# but no bundled refresh token is cached, or when own-flavor OAuth completes but bundled +# RT is still absent. Best-effort signal — the render-time probe in Settings.pm is +# authoritative. Self-clears on successful bundled-OAuth via $cache->remove in Callback.pm. +sub _rememberNeedsBundledAuth { + my ($userId) = @_; + return unless defined $userId && length $userId; + my $key = NEEDS_BUNDLED_AUTH_KEY_PREFIX . $userId; + $cache->set($key, 1, NEEDS_BUNDLED_AUTH_TTL); + main::INFOLOG && $log->is_info && + $log->info(sprintf('Flagged user=%s as needing bundled-default OAuth (ttl=%ds)', + $userId, NEEDS_BUNDLED_AUTH_TTL)); +} + +# Flush all bundled-hint cache entries. Called at OAuth completion so any re-OAuth +# invalidates routing decisions made under the previous identity. Event-driven flush +# (rather than TTL shortening) because the 24h TTL is correct when identity is stable; +# only identity transitions (re-OAuth) require an immediate flush. +# +# Slim::Utils::Cache has no prefix-iterate, so we re-derive keys from +# @KNOWN_DEPRECATED_FAMILIES (same list the writer uses) to guarantee no orphaned rows. +sub _flushBundledHints { + my $removed = 0; + for my $rx (@KNOWN_DEPRECATED_FAMILIES) { + my $patternKey = "$rx"; + my $cacheKey = BUNDLED_HINT_KEY_PREFIX . $patternKey; + if (defined $cache->get($cacheKey)) { + $cache->remove($cacheKey); + $removed++; + } + } + main::INFOLOG && $log->is_info && + $log->info(sprintf('Flushed %d bundled-hint cache row(s) (called from OAuth completion)', $removed)); + return $removed; +} + sub _prepareCall { my ($type, $url, $params) = @_; @@ -1482,7 +1769,4 @@ sub _PLAYLIST_CACHE_TTL { Plugins::Spotty::Plugin->hasDefaultIcon() ? 8*3600 : 3600; } -1; - -__DATA__ -3635623730383037336663303438306561393261303737323333636138376264 \ No newline at end of file +1; \ No newline at end of file diff --git a/OPML.pm b/OPML.pm index 1b7397e..29582aa 100755 --- a/OPML.pm +++ b/OPML.pm @@ -291,15 +291,12 @@ sub handleFeed { }; } - # only give access to the tracks list if the user is using his own client ID - if ( _enableAdvancedFeatures() ) { - unshift @$personalItems, { - name => cstring($client, 'PLUGIN_SPOTTY_SONGS_LIST'), - type => 'playlist', - image => IMG_SONG, - url => \&mySongs, - } - } + unshift @$personalItems, { + name => cstring($client, 'PLUGIN_SPOTTY_SONGS_LIST'), + type => 'playlist', + image => IMG_SONG, + url => \&mySongs, + }; my $homeItem = { name => cstring($client, 'PLUGIN_SPOTTY_HOME'), From fc795bb82bcfc329a563972280ca6e752fe0ee44 Mon Sep 17 00:00:00 2001 From: Marek Stiefenhofer Date: Fri, 22 May 2026 09:16:17 +0200 Subject: [PATCH 4/8] fix: OAuth callback flavor threading and bundled-auth UI Settings/Callback.pm propagates the _client_id through the OAuth relay redirect so bundled-flavor authorization codes get exchanged with the correct Client ID. Settings.pm surfaces a "needs bundled auth" banner when the bundled refresh token is missing. strings.txt: updated Client ID description to explain dual-flavor routing; added bundled-auth prompt strings in 7 languages. --- HTML/EN/plugins/Spotty/settings/basic.html | 11 ++ Settings.pm | 25 ++++ Settings/Callback.pm | 130 +++++++++++++++++++-- strings.txt | 32 +++-- 4 files changed, 182 insertions(+), 16 deletions(-) diff --git a/HTML/EN/plugins/Spotty/settings/basic.html b/HTML/EN/plugins/Spotty/settings/basic.html index b1023aa..c24c30c 100644 --- a/HTML/EN/plugins/Spotty/settings/basic.html +++ b/HTML/EN/plugins/Spotty/settings/basic.html @@ -16,12 +16,16 @@ [% END; END %] [% IF credentials; WRAPPER setting desc="" %] + [% IF needsBundledAuth.keys.size %] +
[% "PLUGIN_SPOTTY_BUNDLED_AUTH_NEEDED_SUMMARY" | string %]
+ [% END %] + [% IF needsBundledAuth.keys.size %][% END %] [% FOREACH creds = credentials %] [% accountName = creds.keys.0; userId = creds.values.0 %] @@ -38,6 +42,13 @@ + [% IF needsBundledAuth.keys.size %] + + [% END %] [% END %]
[% "PLUGIN_SPOTTY_ACCOUNT" | string %][% "COLON" | string %] [% "PLUGIN_SPOTTY_IMPORT_LIBRARY"| string %][% "COLON" | string %] [% "PLUGIN_SPOTTY_PRODUCT"| string %][% "COLON" | string %]
+ [% IF needsBundledAuth.${userId} %] + [% "PLUGIN_SPOTTY_BUNDLED_AUTH_LINK_LABEL" | string %] + [% END %] +
diff --git a/Settings.pm b/Settings.pm index 448f49b..f0a8742 100644 --- a/Settings.pm +++ b/Settings.pm @@ -9,6 +9,7 @@ use Slim::Utils::Prefs; use Slim::Utils::Strings qw(string); use Plugins::Spotty::Plugin; use Plugins::Spotty::AccountHelper; +use Plugins::Spotty::API::Token; use Plugins::Spotty::Settings::Auth; use Plugins::Spotty::Settings::Player; use Plugins::Spotty::Settings::PlaylistFolders; @@ -98,6 +99,30 @@ sub handler { } $paramRef->{credentials} = Plugins::Spotty::AccountHelper->getSortedCredentialTupels(); + + my $needsBundledAuth = {}; + # In Standard-User mode (no own Dev ID configured, iconCode == initIcon) the runtime + # dispatches all me/* + browse calls under the bundled flavor directly, so the probe + # below would surface a misleading "Authentication required" banner even when API traffic + # is healthy. Skip the probe in Standard-User mode so the UI is accurate. + unless (Plugins::Spotty::Plugin->hasDefaultIcon()) { + for my $cred (@{$paramRef->{credentials} || []}) { + # cred is { spotifyUsername => cacheFolderName } (from AccountHelper::getSortedCredentialTupels). + # hasRefreshToken needs the Spotify username (the KEY) to build the correct cache-key shape + # (spotty_refresh_token___). The template keys needsBundledAuth on + # the cache-folder name (the VALUE, same as `userId` in the FOREACH loop at basic.html:32), + # so we must populate the hash with the VALUE but probe with the KEY. + my ($spotifyUsername) = keys %$cred; + my ($cacheFolder) = values %$cred; + next unless $cacheFolder; + if (!Plugins::Spotty::API::Token->hasRefreshToken( + undef, flavor => 'bundled', userId => $spotifyUsername)) { + $needsBundledAuth->{$cacheFolder} = 1; + } + } + } + $paramRef->{needsBundledAuth} = $needsBundledAuth; + $paramRef->{displayNames} = { map { my ($id) = each %$_; $id => Plugins::Spotty::AccountHelper->getDisplayName($id); diff --git a/Settings/Callback.pm b/Settings/Callback.pm index b4af5ee..52adeeb 100644 --- a/Settings/Callback.pm +++ b/Settings/Callback.pm @@ -4,16 +4,24 @@ use strict; use Digest::SHA; use JSON::XS::VersionOneAndTwo; -use MIME::Base64 qw(encode_base64); +use MIME::Base64 qw(encode_base64 decode_base64); use Slim::Utils::Cache; use Slim::Utils::Log; use Slim::Utils::Prefs; +# Needed for initIcon() in the flavor-aware OAuth cache write below. +use Plugins::Spotty::Plugin; + use constant CALLBACK_PATH => 'plugins/Spotty/settings/callback'; use constant REDIRECT_PATH => 'plugins/Spotty/settings/redirect'; use constant PKCE_AUTH_URL => 'https://accounts.spotify.com/authorize?client_id=%s&response_type=code&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&scope=%s&state=%s'; use constant PKCE_CODE_VERIFIER_CACHEKEY => 'spotty_auth_code_verifier'; +# Flavor is cached server-side because api.lms-community.org's relay strips Spotify's +# `state` query parameter when bouncing the callback to LMS (only `code` survives). +# This cache key acts as a server-side fallback for oauthCallback to recover the flavor. +use constant OAUTH_PENDING_FLAVOR_CACHEKEY => 'spotty_oauth_pending_flavor'; +use constant OAUTH_PENDING_FLAVOR_TTL => 600; # seconds; one-shot, cleared in oauthCallback use constant CALLBACK_URL => 'https://api.lms-community.org/auth/callback'; use constant REGISTER_CALLBACK_URL => 'https://api.lms-community.org/auth/prepare'; @@ -107,14 +115,34 @@ sub oauthRedirect { $cache->set(PKCE_CODE_VERIFIER_CACHEKEY, $code_verifier); + # Flavor-aware client_id selection without pref mutation. + # When ?flavor=bundled is present, use the bundled-default Client ID. + my $flavor = (($params->{flavor} // '') eq 'bundled') ? 'bundled' : 'own'; + my $clientId = ($flavor eq 'bundled') + ? Plugins::Spotty::Plugin->initIcon() + : $prefs->get('iconCode'); + + # Cache the flavor server-side so oauthCallback can recover it if the relay + # strips the `state` parameter. One-shot — cleared in oauthCallback. + $cache->set(OAUTH_PENDING_FLAVOR_CACHEKEY, $flavor, OAUTH_PENDING_FLAVOR_TTL); + + # Build the OAuth state value as URL-safe base64 with no embedded newlines. + # encode_base64 with empty eol suppresses \n; translate to base64url alphabet + # and strip = padding before placing in the query string. + my $stateJson = to_json({ + nonce => $nonce, + flavor => $flavor, + }); + my $stateB64 = encode_base64($stateJson, ''); + $stateB64 =~ tr|+/|-_|; + $stateB64 =~ s/=+\z//; + my $url = sprintf(PKCE_AUTH_URL, - $prefs->get('iconCode'), + $clientId, CALLBACK_URL, $code_challenge, SCOPE, - encode_base64(to_json({ - nonce => $nonce - })), + $stateB64, ); my $response = $args[1]; @@ -161,6 +189,30 @@ sub oauthCallback { my $code = $params->{code}; + # Decode the OAuth state param to recover the flavor. Spotify echoes `state` verbatim; + # callbacks without a flavor in state leave $params->{flavor} undef and the downstream + # decision falls through to the iconCode-vs-initIcon test (backward-compat preserved). + if ($params->{state}) { + # Reverse the base64url substitution from oauthRedirect and restore = padding. + # eval-wrap + ref/defined guards survive malformed payloads (legacy callbacks, + # attacker-injected garbage). + my $b64 = $params->{state}; + $b64 =~ tr|-_|+/|; + $b64 .= '=' x ((4 - length($b64) % 4) % 4); + my $decodedState = eval { from_json(decode_base64($b64)) }; + if (ref $decodedState eq 'HASH' && defined $decodedState->{flavor}) { + $params->{flavor} = $decodedState->{flavor}; + } + } + + # Relay-strip fallback: if the relay stripped `state`, recover flavor from the + # LMS-side cache oauthRedirect populated. Clear the cache entry in both cases. + if (!defined $params->{flavor}) { + my $cachedFlavor = $cache->get(OAUTH_PENDING_FLAVOR_CACHEKEY); + $params->{flavor} = $cachedFlavor if defined $cachedFlavor && length $cachedFlavor; + } + $cache->remove(OAUTH_PENDING_FLAVOR_CACHEKEY); + my $renderCb = sub { my $error = shift; @@ -170,7 +222,10 @@ sub oauthCallback { }; main::INFOLOG && $log->is_info && $log->info("Exchange code for access token"); - my $defaultCode = $prefs->get('iconCode'); + + # Re-read iconCode at OAuth completion (inside the me-callback), not at OAuth start. + # The pref value captured here is the authoritative $currentCode for both the flavor + # decision below and the spotty helper binary --client-id arg further down. my $api = Plugins::Spotty::API->new({ noProfileUpdate => 1 }); $api->codeExchange( @@ -195,14 +250,54 @@ sub oauthCallback { $log->warn(sprintf("Authenticated Spotify user: %s (%s, %s)", $userId, $meResult->{display_name} || 'no display name', $meResult->{product} || 'no product info')); - Plugins::Spotty::API::Token->cacheAccessToken($defaultCode, $userId, $accessToken, $result->{expires_in}); - Plugins::Spotty::API::Token->cacheRefreshToken($defaultCode, $userId, $refreshToken) if $refreshToken; + my $currentCode = $prefs->get('iconCode'); + + # Flush the bundled-hint cache on successful OAuth completion (own or bundled). + # Routing decisions cached under the old identity may no longer be correct + # under the new identity; flushing forces the next browse cycle to re-learn. + Plugins::Spotty::API::_flushBundledHints(); + + # Self-heal the needs-bundled-auth cache flag on successful OAuth completion. + # The render-time probe in Settings.pm is authoritative; clearing here avoids + # a brief flicker of a stale prompt on the next Settings render. + # Note: NEEDS_BUNDLED_AUTH_KEY_PREFIX() is called with explicit parens because + # this file does not `use Plugins::Spotty::API` and bare constants from another + # package can be mis-parsed as barewords under `use strict`. + $cache->remove( + Plugins::Spotty::API::NEEDS_BUNDLED_AUTH_KEY_PREFIX() . $userId + ) if $userId; + + # Flavor decision: when ?flavor=bundled flowed through state-JSON, land the RT + # under the bundled-flavor cache key regardless of current iconCode. + # When the param is absent (legacy callbacks, manual OAuth), fall back to + # comparing $currentCode to initIcon(). + my $oauthFlavor = ((($params->{flavor} // '') eq 'bundled') + ? 'bundled' + : (($currentCode eq Plugins::Spotty::Plugin->initIcon()) ? 'bundled' : 'own')); + + # When bundled-flavor, the cache-key $code segment MUST equal what + # Token::hasRefreshToken(flavor=>'bundled') derives at probe time + # (Plugin->initIcon()). Otherwise the bundled RT lands under + # __bundled but the probe looks under + # __bundled — a permanent miss. + # $prefs is NOT mutated; this is a per-call $code arg override only. + my $oauthCode = ($oauthFlavor eq 'bundled') + ? Plugins::Spotty::Plugin->initIcon() + : $currentCode; + Plugins::Spotty::API::Token->cacheAccessToken($oauthCode, $userId, $accessToken, $result->{expires_in}, $oauthFlavor); + Plugins::Spotty::API::Token->cacheRefreshToken($oauthCode, $userId, $refreshToken, $oauthFlavor) if $refreshToken; + + # When flavor=bundled, the spotty helper subprocess must receive the bundled + # Client ID so the AT it stores is keyed under the bundled flavor. + my $helperClientId = ($oauthFlavor eq 'bundled') + ? Plugins::Spotty::Plugin->initIcon() + : $currentCode; # TODO - async token refresh, timeout my $cmd = sprintf('"%s" -n "Squeezebox" -c "%s" --client-id "%s" --disable-discovery --get-token --scope "%s" %s', scalar Plugins::Spotty::Helper->get(), Plugins::Spotty::Settings::Auth->_cacheFolder(), - $prefs->get('iconCode') || $defaultCode, + $helperClientId, SCOPE, '--access-token=' . $accessToken, ); @@ -210,6 +305,16 @@ sub oauthCallback { Plugins::Spotty::API::logSensitive($cmd); `$cmd 2>&1`; + + # Post-OAuth probe: when this was an own-flavor OAuth completion and the user + # has no bundled-flavor RT cached, set the needs-bundled-auth flag so the + # next Settings render surfaces the "Authorize browsing" link proactively. + # Skip when this completion was itself bundled OAuth — flag already cleared above. + if ($oauthFlavor eq 'own' && $userId + && !Plugins::Spotty::API::Token->hasRefreshToken( + $api, flavor => 'bundled', userId => $userId)) { + Plugins::Spotty::API::_rememberNeedsBundledAuth($userId); + } } $renderCb->($error); @@ -225,10 +330,17 @@ sub oauthCallback { $renderCb->($error); }, + # Flavor-aware _client_id for the /api/token authorization_code exchange. + # Mirrors oauthRedirect: when state-JSON decoded $params->{flavor} == 'bundled', + # the /authorize URL was built with the bundled initIcon, so the exchange must + # use the same Client ID. $prefs is NOT mutated; per-call _client_id arg only. { code => $params->{code}, callbackUrl => CALLBACK_URL, codeVerifier => $cache->get(PKCE_CODE_VERIFIER_CACHEKEY), + _client_id => ((($params->{flavor} // '') eq 'bundled') + ? Plugins::Spotty::Plugin->initIcon() + : $prefs->get('iconCode')), }, ); } diff --git a/strings.txt b/strings.txt index 649f963..ca44a3d 100644 --- a/strings.txt +++ b/strings.txt @@ -802,49 +802,49 @@ PLUGIN_SPOTTY_CLIENT_ID HU Spotify ügyfél-azonosító (Client ID) PLUGIN_SPOTTY_CLIENT_ID_DESC - CS
Použití vlastního Client ID může pomoci, pokud narazíte na problémy s překročením míry přístupů („chyba 429“). Chcete-li získat vlastní Client ID, postupujte podle následujících jednoduchých kroků:
+ CS
Použití vlastního Spotify Developer Client ID vám poskytne samostatnou kvótu API oddělenou od výchozího Spotty — užitečné, pokud sdílíte Spotty s více účty (např. rodinné plány). Navíc Spotty využívá vaše Client ID pro přístup k vaší osobní knihovně (oblíbené skladby, uložená alba, sledované playlisty), což zajišťuje spolehlivější přístup k vašim favoritům. Pozn.: aplikace v dev-mode mají u Spotify menší kvótu a některé koncové body (kurátorské procházení, doporučení, dynamické playlisty) jsou omezené; Spotty tyto požadavky automaticky směruje přes výchozí Client ID. Chcete-li získat vlastní Client ID, postupujte podle těchto jednoduchých kroků:
CS
    CS
  • Přejděte na Spotify Developer Portal a v případě potřeby se přihlaste.
  • CS
  • Klikněte na možnost „Create app“ a postupujte podle pokynů.
  • CS
  • Zkopírujte nově vytvořené Client ID do nastavení Spotty.
  • CS
  • Pokud plánujete se Spotty používat více účtů Spotify (např. rodinný účet), budete je muset přidat do části „User Management“ v nově vytvořené konfiguraci.
  • CS
- DA
Brug af et brugerdefineret klient ID kan hjælpe, hvis du støder på problemer med hastighedsbegrænsning ("fejl 429"). Følg disse enkle trin for at få dit eget klient ID:
+ DA
Brug af dit eget Spotify Developer Client ID giver dig en separat API-kvote adskilt fra Spotty's standard — nyttigt hvis du deler Spotty med flere konti (f.eks. familieabonnementer). Derudover bruger Spotty dit Client ID til at tilgå dit personlige bibliotek (yndlingsnumre, gemte albums, fulgte playlister), hvilket giver mere pålidelig adgang til dine favoritter. Bemærk: Spotify's dev-mode apps har en mindre kvote og nogle endpoints (kurateret browsing, anbefalinger, dynamiske playlister) er begrænsede; Spotty dirigerer automatisk disse kald gennem standard Client ID. Følg disse enkle trin for at få dit eget Client ID:
DA
    DA
  • Gå til Spotify Developer Portal, login hvis nødvendigt.
  • DA
  • Klik "Create app", følg instruktionerne.
  • DA
  • Kopier det oprettede klient ID til dine Spotty indstillinger.
  • DA
  • Hvis du planlægger at bruge flere Spotify-konti med Spotty (f.eks. en familiekonto), skal du tilføje konti til afsnittet "Brugere og adgang" i den nyoprettede konfiguration.
  • DA
- DE
Die Verwendung einer eigenen Client ID kann helfen, falls Sie Probleme mit dem "Rate Limiting" haben (Fehler 429). Um eine solche Client ID zu erhalten, befolgen Sie diese einfachen Schritte:
+ DE
Mit Ihrer eigenen Spotify Developer Client ID erhalten Sie eine separate API-Quote, getrennt vom Standard von Spotty — nützlich, wenn Sie Spotty mit mehreren Konten teilen (z.B. Familien-Tarife). Außerdem nutzt Spotty Ihre Client ID für den Zugriff auf Ihre persönliche Bibliothek (Lieder, gespeicherte Alben, gefolgte Playlisten), was einen zuverlässigeren Zugriff auf Ihre Favoriten ermöglicht. Hinweis: Spotify-Apps im Dev-Modus haben eine kleinere Quote und einige Endpunkte (kuratiertes Stöbern, Empfehlungen, dynamische Playlisten) sind eingeschränkt; Spotty leitet diese Aufrufe automatisch über die Standard-Client-ID weiter. Um eine eigene Client ID zu erhalten, befolgen Sie diese einfachen Schritte:
DE
    DE
  • Besuchen Sie das Spotify Developer Portal, melden Sie sich falls nötig an.
  • DE
  • Klicken Sie "Create app", und folgen Sie den Anweisungen.
  • DE
  • Kopieren Sie die neu erstellte Client ID, und fügen Sie sie in den Spotty Einstellungen ein.
  • DE
  • Falls Sie einen Familienaccount verwenden, oder sonst mehrere Konten in Spotty verwenden möchten, fügen Sie die entsprechenden Konten bei der neu erstellten Konfiguration unter "User Management" hinzu.
  • DE
- EN
Using a custom Client ID can help if you hit issues with rate limiting ("error 429"). In order to get your own Client ID please follow these simple steps:
+ EN
Using your own Spotify Developer Client ID gives you a separate API quota bucket from the bundled Spotty default — useful if you share Spotty with multiple accounts (e.g. family plans). Additionally, Spotty uses your Client ID to access your personal library (Liked Songs, saved albums, followed playlists), providing more reliable access to your favorites. Note: Spotify's dev-mode apps have a smaller quota and some endpoints (curated browse, recommendations, dynamic playlists) are restricted; Spotty automatically routes those calls through the bundled default Client ID. To get your own Client ID:
EN
    EN
  • Go to the Spotify Developer Portal, sign in if needed.
  • EN
  • Click "Create app", follow the instructions.
  • EN
  • Copy the newly created Client ID to your Spotty Settings.
  • EN
  • If you plan to use multiple Spotify accounts with Spotty (eg. a family account), you'll need to add the accounts to the "User Management" section in the newly created configuration.
  • EN
- FR
L'utilisation d'un ID client personnalisé peut vous aider si vous rencontrez des problèmes de limitation de débit ("erreur 429"). Pour obtenir votre propre identifiant client, veuillez suivre ces étapes simples :
+ FR
L'utilisation de votre propre Spotify Developer Client ID vous donne un quota d'API distinct de celui par défaut de Spotty — utile si vous partagez Spotty avec plusieurs comptes (par ex. comptes famille). De plus, Spotty utilise votre Client ID pour accéder à votre bibliothèque personnelle (titres favoris, albums sauvegardés, playlists suivies), ce qui offre un accès plus fiable à vos favoris. Note : les apps Spotify en mode développeur ont un quota plus petit et certains points d'accès (parcours organisé, recommandations, playlists dynamiques) sont restreints ; Spotty redirige automatiquement ces appels via l'ID client par défaut. Pour obtenir votre propre identifiant client, suivez ces étapes simples :
FR
    FR
  • Accédez au portail des développeurs Spotify et connectez-vous si nécessaire.
  • FR
  • Cliquez sur "Create app" et suivez les instructions.
  • FR
  • Copiez l'ID client nouvellement créé dans vos paramètres Spotty.
  • FR
  • Si vous prévoyez d'utiliser plusieurs comptes Spotify avec Spotty (par exemple, un compte familial), vous devrez ajouter les comptes à la section "Utilisateurs et accès" dans la configuration nouvellement créée.
  • FR
- HU
Egyéni ügyfél-azonosító használata segíthet, ha problémába ütközik a sebességkorlátozással ("429-es hiba"). A saját ügyfél-azonosító megszerzéséhez kövesse az alábbi egyszerű lépéseket:
+ HU
A saját Spotify Developer ügyfél-azonosító használata külön API kvótát biztosít a Spotty alapértelmezettjétől — hasznos, ha a Spotty-t több fiókkal osztja meg (pl. családi csomagok). Ezenkívül a Spotty az Ön Client ID-ját használja a személyes könyvtárához való hozzáféréshez (kedvenc dalok, mentett albumok, követett lejátszási listák), megbízhatóbb hozzáférést biztosítva kedvenceihez. Megjegyzés: a Spotify dev-módú alkalmazásai kisebb kvótát kapnak, és bizonyos végpontok (válogatott böngészés, ajánlások, dinamikus lejátszási listák) korlátozottak; a Spotty ezeket a hívásokat automatikusan az alapértelmezett ügyfél-azonosítón keresztül irányítja. A saját ügyfél-azonosító megszerzéséhez kövesse az alábbi egyszerű lépéseket:
HU
    HU
  • Nyissa meg a Spotify fejlesztői portált, és jelentkezzen be, ha szükséges.
  • HU
  • Kattintson az "Alkalmazás létrehozása" lehetőségre, és kövesse az utasításokat.
  • HU
  • Másolja az újonnan létrehozott ügyfél-azonosítót a Spotty beállításokba.
  • HU
  • Ha több Spotify-fiókot szeretne használni a Spotty szolgáltatással (pl. családi fiók), akkor hozzá kell adnia a fiókokat az újonnan létrehozott konfiguráció „Felhasználók és hozzáférés” részéhez.
  • HU
- NL
Het gebruik van een aangepaste Client ID kan helpen als er problemen zijn met de snelheidsbeperking ("error 429"). Volg deze eenvoudige stappen om een eigen klant-ID te krijgen:
+ NL
Met je eigen Spotify Developer Client ID krijg je een aparte API-quota, gescheiden van die van Spotty — handig als je Spotty deelt met meerdere accounts (bijv. familieplannen). Daarnaast gebruikt Spotty je Client ID om toegang te krijgen tot je persoonlijke bibliotheek (favoriete nummers, opgeslagen albums, gevolgde afspeellijsten), wat zorgt voor betrouwbaardere toegang tot je favorieten. Let op: Spotify-apps in dev-modus hebben een kleinere quota en sommige endpoints (geredigeerde browse, aanbevelingen, dynamische afspeellijsten) zijn beperkt; Spotty stuurt deze aanroepen automatisch via de standaard Client ID. Volg deze eenvoudige stappen om je eigen Client ID te krijgen:
NL
    NL
  • Ga naar de Spotify Developer Portal, log indien nodig in.
  • NL
  • Klik op "Create app", volg de instructies.
  • @@ -1192,3 +1192,21 @@ PLUGIN_SPOTTY_PLAYLIST_TRACKS FR Morceaux de la liste de lecture HU Lejátszási lista zeneszámok NL Afspeellijst Nummers + +PLUGIN_SPOTTY_BUNDLED_AUTH_NEEDED_SUMMARY + CS Některé účty vyžadují další autorizaci pro plné zpřístupnění nabídky procházení (Mix týdne, Release Radar, Discover Weekly atd.). + DA Nogle konti kræver yderligere godkendelse for at aktivere den fulde browsermenuen (Ugens mix, Release Radar, Discover Weekly osv.). + DE Einige Konten benötigen eine zusätzliche Autorisierung, um das vollständige Stöbern-Menü zu aktivieren (Mix der Woche, Release Radar, Discover Weekly usw.). + EN Some accounts need additional authorization to enable the full browse menu (Mix of the Week, Release Radar, Discover Weekly etc.). + FR Certains comptes nécessitent une autorisation supplémentaire pour activer le menu de navigation complet (Mix de la semaine, Release Radar, Discover Weekly, etc.). + HU Néhány fiókhoz további engedélyezés szükséges a teljes böngészési menü aktiválásához (Heti mix, Release Radar, Discover Weekly stb.). + NL Sommige accounts hebben aanvullende autorisatie nodig om het volledige browsmenu te activeren (Mix van de week, Release Radar, Discover Weekly enz.). + +PLUGIN_SPOTTY_BUNDLED_AUTH_LINK_LABEL + CS Autorizovat procházení + DA Autoriser browsing + DE Stöbern autorisieren + EN Authorize browsing + FR Autoriser la navigation + HU Böngészés engedélyezése + NL Browsen autoriseren From 6ecce7d2ff1cb920d20f72280596c87b3c982e8f Mon Sep 17 00:00:00 2001 From: Marek Stiefenhofer Date: Fri, 22 May 2026 09:16:23 +0200 Subject: [PATCH 5/8] fix: prevent infinite recursion in AccountHelper on account delete AccountHelper.pm: deleteCacheFolder now scrubs both own and bundled flavor refresh tokens and prefs bindings. Unlinks corrupted credentials.json before recursive delete to prevent getCredentials re-entering deleteCacheFolder. PlaylistFolders.pm: minor cleanup (unused import removal). --- AccountHelper.pm | 49 ++++++++++++++++++++++++++++++++++++++++++++++ PlaylistFolders.pm | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/AccountHelper.pm b/AccountHelper.pm index d05022a..977a38c 100644 --- a/AccountHelper.pm +++ b/AccountHelper.pm @@ -12,6 +12,8 @@ use Scalar::Util qw(blessed); use Slim::Utils::Log; use Slim::Utils::Prefs; +use Plugins::Spotty::API::Token; + use constant CACHE_PURGE_INTERVAL => 86400; use constant CACHE_PURGE_MAX_AGE => 60 * 60; use constant CACHE_PURGE_INTERVAL_COUNT => 15; @@ -132,6 +134,12 @@ sub renameCacheFolder { $newId = substr( md5_hex(Slim::Utils::Unicode::utf8toLatin1Transliterate($credentials->{username} || '')), 0, 8 ); } + # Silence the misleading backtrace when Settings::Auth::cleanup() invokes us with the + # __AUTHENTICATE__ sentinel after the OAuth-pre-completion subdir was already removed: + # there is no credentials.json to read, the getCredentials-derivation block above + # produced no $newId, and the rename is a no-op anyway. + return if defined($oldId) && $oldId eq '__AUTHENTICATE__' && !$newId; + main::INFOLOG && $log->info("Trying to rename $oldId to $newId"); if (main::DEBUGLOG && $log->is_debug && !$newId) { @@ -181,11 +189,47 @@ sub removeAllAccounts { sub deleteCacheFolder { my ($class, $id) = @_; + # Orphan-state scrub for the deleted account: (a) drop refresh-token cache rows in + # spotty.db for this account's userId under both 'own' and 'bundled' flavors; (b) scrub + # `_client:: account: ` bindings on any player roster entry currently bound to + # this id. credentials.json MUST be read BEFORE unlink to extract the username; if it + # was already gone, the whole orphan-scrub block silently no-ops. + # + # Token-cache scrub goes through Plugins::Spotty::API::Token->removeRefreshToken which + # wraps Plugins::Spotty::API::Cache->remove. AccountHelper.pm stays agnostic to Token + # cache-key internals. + # + # Player-prefs scrub uses $prefs->client($client)->remove('account') — the documented + # cleanup primitive. Players reconcile to a default account on next play via + # getAccount's lazy-fallback. + my $credentials = $class->getCredentials($id); + my $userId = $credentials && ref $credentials ? $credentials->{username} : undef; + if ( my $credentialsFile = $class->hasCredentials($id) ) { unlink $credentialsFile; $credsCache = undef; } + if ($userId) { + main::INFOLOG && $log->is_info && $log->info("Removing account data for id=$id userId=$userId"); + + # (a) Refresh-token cache scrub — both flavors. + Plugins::Spotty::API::Token->removeRefreshToken(undef, $userId, 'own'); + Plugins::Spotty::API::Token->removeRefreshToken(undef, $userId, 'bundled'); + + # (b) Player-prefs scrub — iterate live roster, remove 'account' binding from any + # client whose account == the deleted $id. Mirrors setAccount / getAccount primitive. + foreach my $client ( Slim::Player::Client::clients() ) { + next unless $client; + my $bound = $prefs->client($client)->get('account'); + next unless defined $bound && $bound eq $id; + + $prefs->client($client)->remove('account'); + $client->pluginData( api => '' ); + main::INFOLOG && $log->is_info && $log->info("Cleared player account binding: client=" . $client->id . " (was bound to $id)"); + } + } + $class->purgeCache(); } @@ -315,6 +359,11 @@ sub getCredentials { require File::Copy; File::Copy::copy($credentialsFile, $backupName); + # Remove the corrupted credentials file before recursive cleanup. deleteCacheFolder + # calls getCredentials($id) to read the username for orphan-state scrub; without this + # unlink the recursive call would re-enter this corruption block on the same content + # (File::Copy::copy leaves the original in place), looping until stack exhaustion. + unlink $credentialsFile; $class->deleteCacheFolder($id); } diff --git a/PlaylistFolders.pm b/PlaylistFolders.pm index 153116a..335a617 100755 --- a/PlaylistFolders.pm +++ b/PlaylistFolders.pm @@ -36,7 +36,7 @@ my $cache = Slim::Utils::Cache->new(); my $log = logger('plugin.spotty'); # the file upload is handled through a custom request handler, dealing with multi-part POST requests -Slim::Web::Pages->addRawFunction("plugins/spotty/uploadPlaylistFolderData", \&handleUpload); +Slim::Web::Pages->addRawFunction("plugins/spotty/uploadPlaylistFolderData", \&handleUpload) if main::WEBUI; Slim::Web::Pages->addPageFunction("plugins/spotty/playlistFolder", \&handlePage) if main::WEBUI; sub parse { From e93b7cf93158e74d7166768949ba72cdd506408f Mon Sep 17 00:00:00 2001 From: Marek Stiefenhofer Date: Fri, 22 May 2026 09:28:45 +0200 Subject: [PATCH 6/8] chore: drop whitespace-only Pipeline.pm changes --- API/Pipeline.pm | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/API/Pipeline.pm b/API/Pipeline.pm index 6676fc5..764e38d 100644 --- a/API/Pipeline.pm +++ b/API/Pipeline.pm @@ -42,7 +42,7 @@ sub new { $self->_data({}); $self->_chunks(delete $self->params->{chunks} || {}); - + return $self; } @@ -58,14 +58,14 @@ sub get { else { $self->spottyAPI->_call($self->method, sub { my ($result, $response) = @_; - + # tell follow-up queries to return cached data without re-validation, if we got a cached result back if ($response && ref $response && $response->headers && ref $response->headers && $response->headers->{'x-spotty-cached-response'}) { $self->params->{_no_revalidate} = 1; } - + my ($count, $next) = $self->_extract(0, $result); - + # warn Data::Dump::dump($count, $self->params->{limit}, $self->limit, SPOTIFY_LIMIT, $next); # no need to run more requests if there's no more than the received results if ( $count <= $self->params->{limit} || $self->limit <= $self->params->{limit} ) { @@ -116,10 +116,10 @@ sub _iterateChunks { sub _followAfter { my ($self, $id) = @_; - + $self->spottyAPI->_call($self->method, sub { my ($count, $next) = $self->_extract($id, shift); - + if ( $next && $next !~ /\boffset=/ && $next =~ /\bafter=([a-zA-Z0-9]{22})\b/ ) { $self->_followAfter($1); } From 85f5d2ec439eb6526b66d6001f06e7b485460f40 Mon Sep 17 00:00:00 2001 From: Marek Stiefenhofer Date: Sat, 23 May 2026 20:40:46 +0200 Subject: [PATCH 7/8] fix: revert package name change in AsyncRequestLegacy.pm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package declaration is intentionally AsyncRequest, not AsyncRequestLegacy — LMS loads it via require and references the class by that name. Co-Authored-By: Claude Opus 4.6 (1M context) --- API/AsyncRequestLegacy.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API/AsyncRequestLegacy.pm b/API/AsyncRequestLegacy.pm index 9d10ab6..398b639 100644 --- a/API/AsyncRequestLegacy.pm +++ b/API/AsyncRequestLegacy.pm @@ -1,4 +1,4 @@ -package Plugins::Spotty::API::AsyncRequestLegacy; +package Plugins::Spotty::API::AsyncRequest; =pod This class extends Slim::Networking::SimpleAsyncHTTP to add PUT support, From 382509a8668f77e37b7adf08cd64f803b33b68bc Mon Sep 17 00:00:00 2001 From: Marek Stiefenhofer Date: Tue, 26 May 2026 08:10:12 +0200 Subject: [PATCH 8/8] fix: address review feedback (CR1, CR2, CR4, CR5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountHelper: move player-prefs cleanup outside if($userId) guard so bindings are cleared even when credentials.json is missing - API: restore browse/* to token-scoped cache key to prevent personalized responses leaking between accounts - Token: also remove legacy 3-segment RT key in removeRefreshToken to prevent re-migration of deleted tokens - strings.txt: fix Dutch typo browsmenu → browsemenu Co-Authored-By: Claude Opus 4.6 (1M context) --- API.pm | 5 +---- API/Token.pm | 9 ++++++++- AccountHelper.pm | 21 +++++++++++---------- strings.txt | 2 +- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/API.pm b/API.pm index 3ec3156..790e8c3 100644 --- a/API.pm +++ b/API.pm @@ -1266,10 +1266,7 @@ sub _callOneShot { my $cached; my $cache_key; if (!$params->{_nocache} && $type eq 'GET') { - # Strip the bearer from the cache key for non-me/* URLs so own-flavor and - # bundled-flavor browse responses share a single cache row. `me/*` continues - # to scope by token (different users see different Liked Songs). - $cache_key = md5_hex($url . ($url =~ /^me\b/ ? $token : '')); + $cache_key = md5_hex($url . ($url =~ /^(?:me|browse)\b/ ? $token : '')); } main::INFOLOG && $log->is_info && $cache_key && $log->info("Trying to read from cache for $url"); diff --git a/API/Token.pm b/API/Token.pm index 6c4199c..1a9c55a 100644 --- a/API/Token.pm +++ b/API/Token.pm @@ -184,7 +184,14 @@ sub removeRefreshToken { } main::INFOLOG && $log->is_info && $log->info("Removing refresh token for $userId (flavor=$flavor)."); eval { $spottyCache->remove(_getRTCacheKey($code, $userId, $flavor)) }; - $log->warn("removeRefreshToken: cache layer threw on remove for $userId (flavor=$flavor): $@") if $@; + $log->warn("removeRefreshToken: new-key remove failed for $userId (flavor=$flavor): $@") if $@; + if ($flavor eq 'own') { + my $legacyKey = _getRTCacheKeyLegacy($code, $userId); + eval { $spottyCache->remove($legacyKey) }; + $log->warn("removeRefreshToken: legacy spottyCache remove failed for $userId: $@") if $@; + eval { $cache->remove($legacyKey) }; + $log->warn("removeRefreshToken: legacy cache remove failed for $userId: $@") if $@; + } } # singleton shortcut to the main class diff --git a/AccountHelper.pm b/AccountHelper.pm index 977a38c..0a2e348 100644 --- a/AccountHelper.pm +++ b/AccountHelper.pm @@ -216,18 +216,19 @@ sub deleteCacheFolder { # (a) Refresh-token cache scrub — both flavors. Plugins::Spotty::API::Token->removeRefreshToken(undef, $userId, 'own'); Plugins::Spotty::API::Token->removeRefreshToken(undef, $userId, 'bundled'); + } - # (b) Player-prefs scrub — iterate live roster, remove 'account' binding from any - # client whose account == the deleted $id. Mirrors setAccount / getAccount primitive. - foreach my $client ( Slim::Player::Client::clients() ) { - next unless $client; - my $bound = $prefs->client($client)->get('account'); - next unless defined $bound && $bound eq $id; + # (b) Player-prefs scrub — iterate live roster, remove 'account' binding from any + # client whose account == the deleted $id. Matches on $id (always available), not + # $userId, so it runs even when credentials.json is missing/corrupt. + foreach my $client ( Slim::Player::Client::clients() ) { + next unless $client; + my $bound = $prefs->client($client)->get('account'); + next unless defined $bound && $bound eq $id; - $prefs->client($client)->remove('account'); - $client->pluginData( api => '' ); - main::INFOLOG && $log->is_info && $log->info("Cleared player account binding: client=" . $client->id . " (was bound to $id)"); - } + $prefs->client($client)->remove('account'); + $client->pluginData( api => '' ); + main::INFOLOG && $log->is_info && $log->info("Cleared player account binding: client=" . $client->id . " (was bound to $id)"); } $class->purgeCache(); diff --git a/strings.txt b/strings.txt index ca44a3d..a39e83c 100644 --- a/strings.txt +++ b/strings.txt @@ -1200,7 +1200,7 @@ PLUGIN_SPOTTY_BUNDLED_AUTH_NEEDED_SUMMARY EN Some accounts need additional authorization to enable the full browse menu (Mix of the Week, Release Radar, Discover Weekly etc.). FR Certains comptes nécessitent une autorisation supplémentaire pour activer le menu de navigation complet (Mix de la semaine, Release Radar, Discover Weekly, etc.). HU Néhány fiókhoz további engedélyezés szükséges a teljes böngészési menü aktiválásához (Heti mix, Release Radar, Discover Weekly stb.). - NL Sommige accounts hebben aanvullende autorisatie nodig om het volledige browsmenu te activeren (Mix van de week, Release Radar, Discover Weekly enz.). + NL Sommige accounts hebben aanvullende autorisatie nodig om het volledige browsemenu te activeren (Mix van de week, Release Radar, Discover Weekly enz.). PLUGIN_SPOTTY_BUNDLED_AUTH_LINK_LABEL CS Autorizovat procházení