song_info returns GenericError for unauthenticated track lookups.
Fall back to scraping og:title/og:description/og:image from the public
Spotify track page — description format is "Song · Artist · Album · Year".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Album: flatten generator pages with list comprehension, parse flat track.{name,artists,...} directly
- Track: try both URI and URL, report GenericError clearly instead of crashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Track: pass spotify:track:ID URI instead of full https:// URL
- Album: Public().album_info() yields track items (generator); metadata from PublicAlbum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both are the correct unauthenticated API methods on the Public class.
Song class requires a logged-in session and has no public equivalent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Song() requires a client instance as first arg; pass Public() for unauthenticated access
- Album response is data.albumUnion.{name,artists,coverArt,tracks.items} not flat keys
- Add per-field debug logging for track and album responses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Frontend was calling itunes.apple.com directly; browsers block it (no CORS headers).
Added /api/itunes/:endpoint proxy on the backend and updated all itunesSearch/Lookup
calls in api.js to route through it when backendUrl is configured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PublicSong doesn't exist in the installed spotapi version. Now discovers
available class names at startup and picks the right one dynamically.
Missing track/album class no longer breaks playlist resolution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
api.spotify.com/v1/ aggressively rate-limits server IPs regardless of
TLS fingerprint or token. SpotipyFree was already hitting the correct
endpoint (api-partner.spotify.com) but crashed on KeyError: 'ownerV2'
due to its own outdated field parsing — not a network block.
spotapi uses the same internal GraphQL API (Spotify's web player API)
which is on a separate rate-limit bucket from the public REST endpoint.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server tracks _spRateLimitUntil and rejects retries instantly without
hitting Spotify, returning the remaining wait time. Retrying before the
window expired was resetting Spotify's timer (19s → 7s → 48s).
Frontend now shows a live countdown with a note not to retry early.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SpotipyFree crashes with KeyError: 'ownerV2' — Spotify changed their
internal API response format and the library is out of date.
curl_cffi presents a real Chrome TLS fingerprint so open.spotify.com/api/
server-time responds (plain Node.js TLS is filtered by Spotify's WAF).
Node.js TOTP fallback always failed on this for the same reason — removed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
IP is not actually blocked (api.spotify.com returns 401, not 403/timeout).
Previous rate limits were temporary. Reverting to the clean state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spotify blocks API calls from server IPs (open.spotify.com/api/* times
out, api.spotify.com/v1/ returns 429). Setting SPOTIFY_PROXY routes all
Spotify calls through curl_cffi via that proxy, bypassing the IP ban.
Without proxy, SpotipyFree is still tried (works on non-banned IPs).
A clear tip is printed if SpotipyFree fails without a proxy configured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The curl_cffi fallback in spotify_resolve.py was silently catching
SpotipyFree ImportErrors and hitting api.spotify.com/v1/ directly —
the same rate-limited endpoint. Removed it entirely.
Also explicitly pip-install SpotipyFree before TzurSoffer's package
so the import is always available regardless of dependency resolution.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SpotipyFree wraps SpotAPI which uses tls_client to impersonate Chrome's
TLS fingerprint and hits Spotify's internal endpoints. This is exactly
what TzurSoffer's spotdl fork uses. Removes the complex GQL hash
extraction (which couldn't find hashes in lazy-loaded webpack chunks).
curl_cffi + TOTP kept as fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Log each Python stderr line separately (no 500-char truncation) so
we can see exactly what's happening in the bundle scan
- Try embed page (open.spotify.com/embed/...) first for GQL hashes —
focused app, fewer bundles, GQL chunks directly referenced
- Suppress noisy bundle-fetch errors to keep logs readable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fetchPlaylist/getAlbum/getTrack GraphQL hashes are not in homepage bundles
— they live in lazy-loaded chunks that only appear on the playlist/album/track
page. Fetch the specific page HTML to find the extra JS bundles, then scan
those for the persisted query hash. Also improved hash regex (no semicolon
restriction, wider window, fallback nearest-hex search).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
api.spotify.com/v1 is IP-banned for 24h. Switch to Spotify's internal
partner API (api-partner.spotify.com/pathfinder/v1/query) which uses
separate infrastructure and rate limits. Persisted query hashes are
extracted dynamically from JS bundles alongside the TOTP secret.
Falls back to REST only if GQL hash not found or partner API fails.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
spotdl save hangs for 60s initializing its full pipeline. Rewrite
spotify_resolve.py to use curl_cffi (already installed) which
impersonates Chrome's TLS fingerprint directly. TOTP token fetched
in Python too. RATE_LIMITED propagates without TOTP fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tls-client (used by SpotAPI/spotdl) bundles a native .so that requires
libresolv.so.2 from glibc. Alpine uses musl libc which doesn't provide
this — causing spotdl to crash on import. Debian-based image has glibc.
Also install yt-dlp via direct download (latest) instead of apk.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
spotdl (SpotAPI) works without developer credentials. Remove the
health-check warning, credential error UI, and Client Credentials
fallback that were blocking the importer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
spotdl doesn't handle tracking params in URLs. Also separate stdout/stderr
in Python script and always try parsing stdout before checking exit code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without app-platform/spotify-app-version headers, requests look like
bot traffic and get rate-limited aggressively. Add 300ms delay between
playlist track pages to avoid burst throttling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Retrying with wait times (23s + 60s + 60s) exceeded the 120s global
timeout and showed a generic timeout error. Now returns immediately
with a RATE_LIMITED error and shows the user how long to wait.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dynamically extracts Spotify's TOTP secret from their web-player JS bundle,
computes a 6-digit HMAC-SHA1 code synced to open.spotify.com/api/server-time,
and requests a token from open.spotify.com/api/token — completely bypassing
the rate-limited Client Credentials token. Client Credentials kept as fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Developer Client ID is 24h rate-banned. clienttoken.spotify.com uses
Spotify's own web-player client ID (separate rate-limit bucket).
Also: fast-fail when Retry-After > 60s and invalidate the cached token
so the next request immediately fetches a fresh one.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Cap per-retry wait at 15s (Spotify's Retry-After can be very long)
- Increase global resolve timeout from 25s to 120s so retries can complete
- Log rate-limit waits for visibility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
api.spotify.com negotiates HTTP/2 via TLS ALPN but https.request uses
HTTP/1.1 framing — the protocol mismatch silently hangs the connection.
wget on Alpine doesn't advertise h2 so it works fine. ALPNProtocols:
['http/1.1'] forces HTTP/1.1 on both ends.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- User-Agent header (Spotify may throttle anonymous requests)
- req.setTimeout + socket.setTimeout for belt-and-suspenders
- done() guard prevents double-resolve/reject
- [sp-http] log shows status code of each Spotify response
so logs reveal whether the hang is before or after HTTP headers arrive
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
undici cannot abort a TCP connect in progress even with AbortController.
Node's https.request + socket.setTimeout fires at the OS socket level
and aborts within ms even when the TCP SYN is silently dropped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
undici (Node.js native fetch) silently ignores AbortSignal.timeout when
TCP connect is blocked. Using AbortController driven by Node's setTimeout
guarantees the abort fires and the hanging request is cancelled within 10s.
Adds [spotify] token ok log to confirm when credentials work.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extracts resolve logic into _resolveSpotify() and wraps it in
Promise.race with a 25s timer so the backend always sends a response
even if undici's AbortSignal.timeout silently fails on blocked sockets.
Also reduces frontend timeout from 90s to 30s.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>