Skip to content

Fix: dual-flavor API routing so Songs and Home menu both work#225

Open
stiefenm wants to merge 8 commits into
michaelherger:masterfrom
stiefenm:pr/api-fix
Open

Fix: dual-flavor API routing so Songs and Home menu both work#225
stiefenm wants to merge 8 commits into
michaelherger:masterfrom
stiefenm:pr/api-fix

Conversation

@stiefenm
Copy link
Copy Markdown

@stiefenm stiefenm commented May 22, 2026

Closes #223

Compatibility note

This PR is rebased on v4.60.8 (c1d9dfa, current upstream tip). It is compatible with the new API request header helper (commit 2d8d235): our code does not use the new helper, does not conflict with it, and the two changes are purely additive.

Merge order

This PR is independent and can be merged first. PR #226 (Connect) builds on the Dual-OAuth dispatch introduced here. PR #28 (librespot binary) is the companion binary for PR #226 and does not affect this PR.

Summary

Spotty can be run with or without a Custom Client ID. Previously, each mode exposed a different subset of features: Songs (Liked Songs) required a Custom Client ID, while the Home menu (Made For You, Mix of the Week, Release Radar, Discover Weekly, Genres and Moods) only worked without one.

This PR makes both feature sets work simultaneously:

  • me/* calls (Songs, saved albums, followed playlists) are routed through the user own Client ID when configured, falling back to the bundled default otherwise
  • Browse calls (curated playlists, recommendations, categories) automatically fall back to the bundled default Client ID when the own ID gets a 403/404
  • The _enableAdvancedFeatures() guard in OPML.pm is removed so Songs is visible for all users
  • Pipeline.pm paging and the 429 cooldown gate are cleaned up for more reliable rate-limit handling
  • OAuth callback threading propagates the correct Client ID per flavor through the relay redirect

Commits

  1. Pipeline and AsyncRequest: rate-limit cooldown gate, cache overrides
  2. Token flavor handling: separate cache keys per OAuth flavor, legacy migration, removeRefreshToken
  3. Try-own-then-fallback routing: URL-pattern hint cache, me/* guard, OPML Songs visibility
  4. Settings/Callback: OAuth flavor threading, bundled-auth UI prompt
  5. AccountHelper: recursion prevention on account delete, dual-flavor token cleanup

Files changed

API.pm, API/Token.pm, API/Pipeline.pm, API/AsyncRequest.pm, API/AsyncRequestLegacy.pm, API/Cache.pm, OPML.pm, Settings.pm, Settings/Callback.pm, AccountHelper.pm, Helper.pm, PlaylistFolders.pm, strings.txt, basic.html

Tested on

  • LMS 9.2.0 (build 1778040950) on Debian x86_64, Perl 5.38.2
  • Spotify Premium account with ~1500 Liked Songs
  • Verified: Songs, Home menu (Made For You, Genres and Moods), and Liked Songs paging all work with Custom Client ID configured
  • No 429 errors observed during 3-day daily-use validation

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

📝 Walkthrough

Walkthrough

This PR implements dual-token routing to resolve Spotify API quota constraints by attempting personal client requests first, then conditionally retrying under bundled defaults for deprecated endpoints. It extends token caching with flavor awareness, encodes flavor identity into OAuth state, refactors HTTP dispatch with 429 gating and retry logic, removes feature-blocking guards, and updates account cleanup and UI to reflect bundled-auth status.

Changes

Dual-Token Try-Own-Then-Fallback Feature

Layer / File(s) Summary
Token flavor-aware caching infrastructure
API/Token.pm
Access and refresh token cache keys include a flavor segment (own/bundled); legacy 3-segment keys for own flavor are migrated to 4-segment keys on first read; callback dedup is keyed by (refreshToken, flavor) pair.
OAuth flavor selection and state encoding
Settings/Callback.pm, Helper.pm
Flavor is selected via ?flavor=bundled parameter and encoded into base64url-JSON OAuth state; callback decodes state or falls back to server-side pending flavor cache; OAuth completion derives flavor from state or by comparing current icon code to initIcon().
Core HTTP dispatch with try-own-then-fallback retry
API.pm
Deprecated endpoint families are curated at module load; HTTP dispatch uses a 24h bundled-hint cache to track successful bundled patterns; _callOneShot performs one authenticated request with GET response caching (bearer stripped except me//browse); _call routes to own flavor first, retries under bundled on 403/410/deprecated-404 when bundled token exists, caches matched patterns only on bundled success, and marks users needing bundled auth when refresh token is unavailable.
Keymaster token helper integration
Helper.pm
New getKeymasterToken method executes the Spotty helper subprocess with --keymaster-token flag to obtain access tokens for bundled authentication.
Account deletion with token flavor cleanup
AccountHelper.pm
Account deletion derives userId from credentials, removes refresh tokens for both own and bundled flavors, clears account bindings from live clients; corrupted credentials are unlinked before recursive cleanup to prevent corruption loops.
Settings UI and bundled-auth flag computation
Settings.pm, HTML/EN/plugins/Spotty/settings/basic.html
Settings handler probes each account for bundled-flavor refresh tokens (skipped in Standard-User mode) and populates needsBundledAuth map; credentials table conditionally renders bundled-auth redirect links for flagged users.
Menu feature exposure and handler registration updates
OPML.pm, PlaylistFolders.pm
Songs list is always exposed (unconditional unshift); upload handler registration is gated on WEBUI availability.
Supporting API/cache/translation updates
API/Cache.pm, API/AsyncRequest.pm, strings.txt
Cache.pm adds remove() wrapper method; AsyncRequest.pm now returns Perl truth value; translations updated for Client ID quota guidance and new bundled-auth status labels.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Two pathways now where once was one—
Own and bundled dance as one!
Quotas no longer block the way,
Songs and Home both work today.
OAuth state hops through the air,
Flavor follows everywhere! 🎵

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main objective: enabling dual-flavor API routing so both Songs (me/*) and Home/browse endpoints work simultaneously.
Linked Issues check ✅ Passed The PR implementation addresses all primary objectives from #223: dual-flavor routing for me/* vs browse endpoints, automatic fallback to bundled Client ID on 403/404/410, visibility of Songs menu to all users, and on-demand bundled OAuth prompting in Settings.
Out of Scope Changes check ✅ Passed All changes are directly scoped to objectives in #223: dual-flavor token/routing support, OAuth callback threading, account cleanup, and UI/string updates. No unrelated refactoring or feature additions observed.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@AccountHelper.pm`:
- Around line 213-230: The player binding cleanup loop must always run
regardless of $userId; move the foreach over Slim::Player::Client::clients()
(the block using $prefs->client($client)->get('account'), remove('account') and
$client->pluginData(...)) out of the if ($userId) { ... } guard so live clients
whose account id ($id) was deleted are always cleared, but keep the Tokens
cleanup (Plugins::Spotty::API::Token->removeRefreshToken(...)) inside the
existing if ($userId) branch so refresh-token scrubbing still only runs when
$userId is defined.

In `@API.pm`:
- Around line 1268-1273: The cache key logic currently strips the token for any
non-`me/*` GET, causing personalized endpoints (e.g., `browse/categories/...`)
to be shared across users; change the logic so the token is only stripped for a
small whitelist of known non-personalized endpoints instead of every non-`me`
URL. Modify the condition around $cache_key (the line that sets md5_hex($url .
($url =~ /^me\b/ ? $token : ''))) to preserve the $token by default and only
omit it if $url matches a maintained whitelist (e.g., a regex or
`@non_personal_paths` array of truly public paths), or conversely explicitly
include the token when $url matches patterns that may be personalized (like any
`browse/` subpaths); keep referencing $params->{_nocache}, $type eq 'GET',
$cache_key and $url when implementing the fix.
- Around line 1288-1300: The instantiation in _callOneShot (and similarly in
_tokenCall) always hard-codes Plugins::Spotty::API::AsyncRequest so the legacy
loader is never used; change these call sites to instantiate the class selected
earlier (use the variable or symbol that holds the chosen class name after the
conditional require, e.g. $AsyncRequestClass or the same selector used in
Plugins::Spotty::API::new) instead of Plugins::Spotty::API::AsyncRequest, so
that Plugins::Spotty::API::AsyncRequestLegacy is used on pre-8.5.1 and
Plugins::Spotty::API::AsyncRequest is used otherwise; update both _callOneShot
and _tokenCall to use that selected class.

In `@API/Token.pm`:
- Around line 167-187: removeRefreshToken currently only removes the new RT key
shape via _getRTCacheKey, but get() and hasRefreshToken() still fall back to
_getRTCacheKeyLegacy for flavor => 'own', so the legacy key must also be removed
to avoid re-migration. Update removeRefreshToken to, after attempting the
_getRTCacheKey removal, also attempt a guarded remove of the legacy key when
$flavor eq 'own' by calling $spottyCache->remove(_getRTCacheKeyLegacy(...))
inside an eval and mirror the existing warning log pattern (use the same $userId
and flavor context) so legacy-key removal failures are best-effort and logged
similarly.

In `@strings.txt`:
- Line 1203: The Dutch bundled-auth summary contains a typo: replace the token
"browsmenu" with "browsemenu" inside the string "Sommige accounts hebben
aanvullende autorisatie nodig om het volledige browsmenu te activeren (Mix van
de week, Release Radar, Discover Weekly enz.)." so the sentence reads
"...volledige browsemenu te activeren..." (or a localized equivalent) to correct
the UI text; update only the text fragment in strings.txt.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8decba54-6c9e-4648-b526-b89e88a01e13

📥 Commits

Reviewing files that changed from the base of the PR and between c1d9dfa and 14ddfda.

📒 Files selected for processing (14)
  • API.pm
  • API/AsyncRequest.pm
  • API/AsyncRequestLegacy.pm
  • API/Cache.pm
  • API/Pipeline.pm
  • API/Token.pm
  • AccountHelper.pm
  • HTML/EN/plugins/Spotty/settings/basic.html
  • Helper.pm
  • OPML.pm
  • PlaylistFolders.pm
  • Settings.pm
  • Settings/Callback.pm
  • strings.txt

Comment thread AccountHelper.pm Outdated
Comment thread API.pm
Comment thread API.pm
Comment on lines +1288 to +1300
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,
},
);
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n \
  'require Plugins::Spotty::API::AsyncRequest|require Plugins::Spotty::API::AsyncRequestLegacy|Plugins::Spotty::API::AsyncRequest->new|Plugins::Spotty::API::AsyncRequestLegacy->new' \
  API.pm API/AsyncRequest.pm API/AsyncRequestLegacy.pm

Repository: michaelherger/Spotty-Plugin

Length of output: 320


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show selection/require logic early in API.pm
echo "==== API.pm:120-170 ===="
sed -n '100,180p' API.pm

# Show the first instantiation site around 1288
echo "==== API.pm:1250-1320 ===="
sed -n '1240,1335p' API.pm

# Show the second instantiation site (likely _tokenCall) around 1462
echo "==== API.pm:1410-1510 ===="
sed -n '1410,1515p' API.pm

Repository: michaelherger/Spotty-Plugin

Length of output: 9153


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== API/AsyncRequest.pm (relevant parts) ===="
rg -n 'package Plugins::Spotty::API::AsyncRequest|shouldNotRevalidate|revalidate|no_revalidate|sub new' API/AsyncRequest.pm
sed -n '1,220p' API/AsyncRequest.pm

echo "==== API/AsyncRequestLegacy.pm (relevant parts) ===="
rg -n 'package Plugins::Spotty::API::AsyncRequestLegacy|shouldNotRevalidate|revalidate|no_revalidate|sub new' API/AsyncRequestLegacy.pm
sed -n '1,220p' API/AsyncRequestLegacy.pm

Repository: michaelherger/Spotty-Plugin

Length of output: 5782


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n 'AsyncRequestLegacy->new|AsyncRequest->new' API.pm API/AsyncRequest*.pm
rg -n 'AsyncRequestLegacy' API.pm
rg -n '_tokenCall|_callOneShot|_callOneShot\(' API.pm

Repository: michaelherger/Spotty-Plugin

Length of output: 856


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== API.pm: top (package/inheritance) ===="
sed -n '1,120p' API.pm

echo "==== API.pm: _callOneShot definition header ===="
sed -n '1210,1275p' API.pm

echo "==== API.pm: _tokenCall header area ===="
sed -n '1435,1495p' API.pm

Repository: michaelherger/Spotty-Plugin

Length of output: 9426


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n 'require Plugins::Spotty::API::AsyncRequest\b|Plugins::Spotty::API::AsyncRequest->new\b|AsyncRequest->new\b' .
rg -n 'require Plugins::Spotty::API::AsyncRequestLegacy\b|Plugins::Spotty::API::AsyncRequestLegacy->new\b' .

Repository: michaelherger/Spotty-Plugin

Length of output: 328


Select AsyncRequestLegacy on pre-8.5.1 instead of hard-coding AsyncRequest

Plugins::Spotty::API::new conditionally requires Plugins::Spotty::API::AsyncRequestLegacy for older LMS, but both request paths still instantiate Plugins::Spotty::API::AsyncRequest directly—so the legacy module is never used.

  • _callOneShot (~1288): still uses Plugins::Spotty::API::AsyncRequest->new(...)
	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,
		},
	);
  • _tokenCall (~1462): also hard-codes Plugins::Spotty::API::AsyncRequest->new(...)

Use the same selected class (AsyncRequest vs AsyncRequestLegacy) for both call sites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@API.pm` around lines 1288 - 1300, The instantiation in _callOneShot (and
similarly in _tokenCall) always hard-codes Plugins::Spotty::API::AsyncRequest so
the legacy loader is never used; change these call sites to instantiate the
class selected earlier (use the variable or symbol that holds the chosen class
name after the conditional require, e.g. $AsyncRequestClass or the same selector
used in Plugins::Spotty::API::new) instead of
Plugins::Spotty::API::AsyncRequest, so that
Plugins::Spotty::API::AsyncRequestLegacy is used on pre-8.5.1 and
Plugins::Spotty::API::AsyncRequest is used otherwise; update both _callOneShot
and _tokenCall to use that selected class.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a bug — both AsyncRequest.pm and AsyncRequestLegacy.pm declare the same package name (Plugins::Spotty::API::AsyncRequest). The conditional require in API::new loads the right file based on LMS version; since both register under the same namespace, the hard-coded AsyncRequest->new() calls dispatch correctly. This is intentional Perl convention for compile-time class selection.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "==== API/AsyncRequest.pm: package line ===="
grep -n '^\s*package ' API/AsyncRequest.pm || echo "(not found)"

echo "==== API/AsyncRequestLegacy.pm: package line ===="
grep -n '^\s*package ' API/AsyncRequestLegacy.pm || echo "(not found)"

echo "==== API.pm: conditional require block ===="
sed -n '108,130p' API.pm

echo "==== Full AsyncRequestLegacy.pm ===="
cat API/AsyncRequestLegacy.pm

Length of output: 5902


🏁 Script executed:

#!/bin/bash
# Resolve my own review comment
gh pr view 225 --repo michaelherger/Spotty-Plugin --json reviews,comments 2>/dev/null | head -5

# List review comments by coderabbitai on this PR
gh api repos/michaelherger/Spotty-Plugin/pulls/225/comments \
  --jq '.[] | select(.user.login == "coderabbitai[bot]") | {id: .id, body: (.body | .[0:80])}'

Length of output: 45509


🏁 Script executed:

#!/bin/bash
# Resolve the review comment via GraphQL (minimise it as resolved)
gh api graphql -f query='
mutation {
  resolveReviewThread(input: {threadId: "PRRT_kwDOCrHVZ85Vxb4G"}) {
    thread {
      isResolved
    }
  }
}'

# Alternative: find the thread ID from the pull request review threads
gh api graphql -f query='
{
  repository(owner: "michaelherger", name: "Spotty-Plugin") {
    pullRequest(number: 225) {
      reviewThreads(first: 50) {
        nodes {
          id
          isResolved
          comments(first: 1) {
            nodes {
              databaseId
              body
            }
          }
        }
      }
    }
  }
}'

Length of output: 18994


🏁 Script executed:

#!/bin/bash
gh api graphql -f query='
mutation {
  resolveReviewThread(input: {threadId: "PRRT_kwDOCrHVZ86ECITg"}) {
    thread {
      isResolved
    }
  }
}'

Length of output: 668



@stiefenm — I wasn't able to resolve the thread programmatically (the integration doesn't have that permission), but the correction stands: both files share the same package Plugins::Spotty::API::AsyncRequest declaration, the pattern is intentional, and my original comment was wrong. I've recorded this as a learning so it won't be flagged again in future reviews.

(=^・ω・^=)


✏️ Learnings added
Learnt from: stiefenm
Repo: michaelherger/Spotty-Plugin PR: 225
File: API.pm:1288-1300
Timestamp: 2026-05-26T05:56:39.464Z
Learning: In Spotty-Plugin (`API/AsyncRequest.pm` and `API/AsyncRequestLegacy.pm`), both files intentionally declare the same Perl package name `Plugins::Spotty::API::AsyncRequest`. The conditional `require` in `Plugins::Spotty::API::new` loads the correct file based on LMS version (using `Slim::Networking::SimpleHTTP::Base->can('shouldNotRevalidate')`). Since both files register under the identical namespace, every hard-coded `Plugins::Spotty::API::AsyncRequest->new(...)` call site (including `_callOneShot` and `_tokenCall` in `API.pm`) dispatches correctly to whichever version was loaded. This is a deliberate Perl compile-time class-selection pattern — do not flag as a bug.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread API/Token.pm Outdated
Comment thread strings.txt Outdated
@michaelherger
Copy link
Copy Markdown
Owner

I'm wondering about the UX: would the user have to authenticate twice now? Once with each client ID?

Comment thread API/AsyncRequestLegacy.pm
@@ -1,4 +1,4 @@
package Plugins::Spotty::API::AsyncRequest;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change should be unnecessary and is causing the lengthy debate CodeRabbit is having below...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, that's a leftover from rebasing. The package declaration got changed by accident — the actual fix doesn't need any changes to AsyncRequestLegacy.pm. I'll drop it from the PR.

Comment thread Helper.pm
return wantarray ? @binaries : $binary;
}

sub getKeymasterToken {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are tons of comments everywhere in this PR. But no word about the key master token. What is it? What is it used for?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — should have explained that. When PKCE refresh fails (expired refresh token, revoked access), the user currently has to re-authenticate in the browser. The keymaster token is a fallback: the spotty binary can get a short-lived access token from Spotify's internal key exchange (same mechanism the desktop app uses). Token.pm tries PKCE refresh first, and only falls back to the binary's --keymaster-token if the refresh fails. The user doesn't notice — it just prevents the "please re-authenticate" dead end.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you ever hit that situation? The solution requires a new binary build, which I try to avoid whenever I can, as some platforms are rather picky.

Haven't checked in a while, but what is the expiration duration for the refresh token?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I did hit that situation. PKCE refresh tokens can expire overnight (Spotify doesn't document a fixed TTL, but they do rotate/revoke after periods of inactivity). On a headless multiroom setup, that means the user has to manually re-authenticate in the LMS web UI the next morning — which is a poor experience.

You're right that it requires a binary build, which is a real cost. The feature is a single sub in Helper.pm (~20 lines) that calls the binary with --keymaster-token and parses the JSON output — Token.pm only invokes it when the normal PKCE refresh returns HTTP 400. I can split it out into a separate PR if you'd prefer to evaluate it independently from the dual-token work. That way this PR stays plugin-only without a binary dependency, and the keymaster fallback can be discussed on its own merits once the binary build question is settled.

@stiefenm
Copy link
Copy Markdown
Author

No, the user only authenticates once with their own Client ID — same as before. The bundled default ID doesn't need user authentication, it's used automatically in the background for browse endpoints (categories, featured playlists, etc.) that don't need user context.

@michaelherger
Copy link
Copy Markdown
Owner

No, the user only authenticates once with their own Client ID — same as before. The bundled default ID doesn't need user authentication, it's used automatically in the background for browse endpoints (categories, featured playlists, etc.) that don't need user context.

I'm just trying to understand... how can you have different access tokens for two different client IDs if you only authenticate with one of them? IMHO that would be a flaw in their auth code... but if it works for us 🙈...

@michaelherger
Copy link
Copy Markdown
Owner

Hmm... the string token PLUGIN_SPOTTY_BUNDLED_AUTH_NEEDED_SUMMARY actually suggests you'll need to auth twice!

@stiefenm
Copy link
Copy Markdown
Author

You're right — I oversimplified in my earlier answer. The bundled Client ID does require its own OAuth authorization. The flow is: the user authenticates once with their own Client ID (as before). If browse endpoints start returning 403/410 (Spotify's deprecation signal), the plugin detects this and shows a one-time prompt on the plugin settings page (Settings → Spotty → Basic) — an orange hint text above the account table, plus a per-account "Authorize browsing" link. Clicking that link runs a second OAuth flow against the bundled Client ID. After that, both tokens are cached and refreshed independently.

The UX intent is: everything works as before with the user's own Client ID until Spotify forces the issue. Then the user clicks one extra link. Not seamless — but better than losing the Start menu entirely.

@stiefenm
Copy link
Copy Markdown
Author

Yes, you're right — the string contradicts what I said earlier. See my correction above: there is a second auth step for the bundled Client ID, but it's only triggered on-demand when Spotify rejects the user's own ID for browse endpoints. The settings page shows the prompt only when needed.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
API.pm (1)

1339-1348: 💤 Low value

Minor inefficiency: hint cache lookup in Standard-User mode.

Line 1339 performs a hint cache lookup, but in Standard-User mode (line 1346-1348), $startFlavor is unconditionally overwritten to 'bundled', making the lookup result unused. Consider reordering to skip the lookup when hasDefaultIcon() is true.

This is a minor inefficiency (one hash lookup), not a correctness issue.

♻️ Suggested reordering
 	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.
+	my $startFlavor;
 	if (Plugins::Spotty::Plugin->hasDefaultIcon()) {
 		$startFlavor = 'bundled';
 	}
+	else {
+		# Step 1: hint-cache lookup — for non-me URLs only.
+		my $hintFlavor = $isMeFamily ? undef : _lookupBundledHint($cleanUrl);
+		$startFlavor = $hintFlavor || 'own';
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@API.pm` around lines 1339 - 1348, The hint cache lookup (_lookupBundledHint)
is wasted when Plugins::Spotty::Plugin->hasDefaultIcon() is true because
$startFlavor is immediately set to 'bundled'; to fix, check
Plugins::Spotty::Plugin->hasDefaultIcon() (and preserve the isMeFamily guard)
before calling _lookupBundledHint so you only compute $hintFlavor when needed,
and then set $startFlavor from $hintFlavor || 'own' otherwise.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@API.pm`:
- Around line 1339-1348: The hint cache lookup (_lookupBundledHint) is wasted
when Plugins::Spotty::Plugin->hasDefaultIcon() is true because $startFlavor is
immediately set to 'bundled'; to fix, check
Plugins::Spotty::Plugin->hasDefaultIcon() (and preserve the isMeFamily guard)
before calling _lookupBundledHint so you only compute $hintFlavor when needed,
and then set $startFlavor from $hintFlavor || 'own' otherwise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a999b92c-0c2d-4bab-80b5-4e21e6c14ae6

📥 Commits

Reviewing files that changed from the base of the PR and between 14ddfda and b189c5e.

📒 Files selected for processing (4)
  • API.pm
  • API/Token.pm
  • AccountHelper.pm
  • strings.txt
✅ Files skipped from review due to trivial changes (1)
  • strings.txt

stiefenm and others added 8 commits May 26, 2026 18:16
…oints

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.
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.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.
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.
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).
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Helper.pm`:
- Around line 204-226: The getKeymasterToken function currently interpolates
$cacheDir into a shell string ($cmd) which allows shell injection; change it to
avoid shell interpolation by either validating/sanitizing $cacheDir to reject
shell metacharacters before use or—preferably—invoke the helper without a shell
(use a safe exec/list form or IPC::Open3/IPC::Run) passing $helperPath and the
-c $cacheDir argument as separate params and capture stdout/stderr
programmatically instead of using backticks with "2>/dev/null"; update
references in getKeymasterToken to remove the sprintf $cmd construction and the
backtick call and ensure errors/stderr are handled and logged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 26df69db-b65c-4ec6-8fd5-97a12c6e436b

📥 Commits

Reviewing files that changed from the base of the PR and between b189c5e and 382509a.

📒 Files selected for processing (12)
  • API.pm
  • API/AsyncRequest.pm
  • API/Cache.pm
  • API/Token.pm
  • AccountHelper.pm
  • HTML/EN/plugins/Spotty/settings/basic.html
  • Helper.pm
  • OPML.pm
  • PlaylistFolders.pm
  • Settings.pm
  • Settings/Callback.pm
  • strings.txt
✅ Files skipped from review due to trivial changes (2)
  • API/AsyncRequest.pm
  • strings.txt

Comment thread Helper.pm
Comment on lines +204 to +226
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate or sanitize $cacheDir before shell interpolation.

The $cacheDir parameter is interpolated directly into the shell command at line 211. If an attacker could control $cacheDir, they could inject shell commands (e.g., "; rm -rf /; #).

While $cacheDir appears to come from AccountHelper->cacheFolder() which should be trusted, the function signature accepts any value. Consider adding a guard to reject paths containing shell metacharacters, or use a safer execution method.

Suggested defensive check
 sub getKeymasterToken {
 	my ($class, $cacheDir) = `@_`;

 	my $helperPath = $class->get();
 	return unless $helperPath && $cacheDir && -d $cacheDir;
+	# Reject paths with shell metacharacters as a defense-in-depth measure
+	return if $cacheDir =~ /[`\$;\|&<>(){}'"\\]/;
 	return unless $class->getCapability('keymaster-token');

 	my $cmd = sprintf('%s --keymaster-token -c "%s" 2>/dev/null', $helperPath, $cacheDir);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 getKeymasterToken {
my ($class, $cacheDir) = `@_`;
my $helperPath = $class->get();
return unless $helperPath && $cacheDir && -d $cacheDir;
# Reject paths with shell metacharacters as a defense-in-depth measure
return if $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;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Helper.pm` around lines 204 - 226, The getKeymasterToken function currently
interpolates $cacheDir into a shell string ($cmd) which allows shell injection;
change it to avoid shell interpolation by either validating/sanitizing $cacheDir
to reject shell metacharacters before use or—preferably—invoke the helper
without a shell (use a safe exec/list form or IPC::Open3/IPC::Run) passing
$helperPath and the -c $cacheDir argument as separate params and capture
stdout/stderr programmatically instead of using backticks with "2>/dev/null";
update references in getKeymasterToken to remove the sprintf $cmd construction
and the backtick call and ensure errors/stderr are handled and logged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spotty feature set shouldn't depend on whether a Custom Client ID is configured

2 participants