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>
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>
Backend returns {error:'EDITORIAL_PLAYLIST'} on 403 playlist routes.
Frontend shows a step-by-step workaround card instead of a red error.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the broken open.spotify.com/get_access_token scrape with
accounts.spotify.com/api/token (Basic auth, client_credentials grant).
Adds SPOTIFY_CLIENT_ID/SECRET to config, docker-compose, and .env.example.
Health endpoint now reports spotify.configured; frontend disables the
importer and shows a setup guide when credentials are missing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Uses open.spotify.com/get_access_token for an anonymous bearer token,
then paginates through the full Spotify API — removes the 100-track
embed limit for playlists and albums.
- Add /api/abs-proxy/* endpoint in the backend that forwards all ABS API
calls server-side (X-ABS-URL + X-ABS-Token headers). Browser never
touches ABS directly for API calls — no CORS issue.
- abs.js routes through proxy when backendUrl is set; falls back to direct
if no backend (requires user to configure CORS on ABS server)
- Audio streaming still uses direct <audio src> (HTML media bypasses CORS)
- Settings: status text above buttons, buttons full-width flex row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Using the full artist name with Unicode symbols (e.g. ☆NoAki☆) in ytsearch
caused YouTube to return 0-1 completely irrelevant results, letting wrong songs
fall through to the no-filter fallback. Strip only \p{S} (Unicode symbol chars
like ☆ ★ ♥) but keep ASCII punctuation — so N!GHT stays "N!GHT" (important for
disambiguation) while ☆NoAki☆ becomes "NoAki" (searchable).
YTM URL still uses the original artist name with all chars intact.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use mainArtist (with special chars) for ytsearch queries so e.g. "N!GHT - Yume"
searches correctly instead of "NGHT - Yume" which YouTube misreads as "night"
and returns ambience videos.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- YTM URL now uses unstripped artist name (e.g. "N!GHT" instead of "NGHT") for
better search ranking on YouTube Music
- Steps 1-2 of cascade now include a soft channel ~= filter using the artist's
first word, preventing wrong-artist songs with matching titles from being
downloaded (e.g. a different "Moonlight" for N!GHT)
- Steps 3+ drop the channel filter so label-hosted tracks still get found
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ytdlp.js: use \b word boundaries in all variant filter regexes so that
partial matches like "nightcoreals" containing "nightcore" no longer
falsely reject official videos; also fixes sped regression (!~= \bsped\b
instead of the too-narrow "sped up" pattern)
- squid.js: restructure verify payload to mirror challenge response layout
{ parameters, solution: { number, derivedKey }, signature }
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- squid.js: add top-level signature from challenge response to verify payload
(server added signature validation, causing HTTP 400 without it)
- ytdlp.js: switch variant filter from !*= to !~= (?i) so Remix/REMIX/remix
are all rejected, not just lowercase
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ytdlp.js: watch for [youtube:tab] in yt-dlp output during flat-playlist step;
return [] when channel expansion is detected so cascade falls through to ytsearch
- Revert player.js / main.js / state.js PWA _changingTrack changes (not effective)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /api/altcha/challenge response is { parameters: { nonce, salt, ... } }.
Destructuring the top level left nonce/salt/keyPrefix as undefined, causing
Buffer.from(undefined) crash on every squid.wtf download attempt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--match-filter 'webpage_url !*= /browse/' was applied to individual videos
AFTER yt-dlp expanded an artist channel into its uploads playlist — by then
each video had a /watch?v= URL, so the filter never fired.
New approach: run yt-dlp --flat-playlist --print webpage_url first to get
the raw search result URLs, filter /browse/ entries in Node.js, then pass
only the actual watch?v= video URLs to yt-dlp for download with --max-downloads 1.
The channel is never expanded; we get the real song from the search results.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add --match-filter 'webpage_url !*= /browse/' to baseArgs so yt-dlp
skips YTM artist channel pages returned as search results (previously
caused recursive channel-upload fetches that wasted time)
- Call _renderNotifList() in _addNotif so playlist-created notifications
appear immediately without needing a page reload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parallel downloads to the same album dir (e.g. two tracks from same
album) both resolved to the last-written file, causing wrong metadata
to be embedded. Now each download targets its known output path
(trackPrefix + safeTitle + audioFmt) directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use Unicode property escapes to strip only symbols (☆, !, etc.)
while keeping Unicode letters (ø, ä, é) — fixes "Kløn" becoming
"Kln" instead of staying "Kløn" in the search query.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- buildTitleKeywords: strip parenthetical tags before picking words so
"Eleven Night (Jersey Club)" → keywords ["Eleven","Night"] instead of
["Eleven","Jersey"] — "Jersey" was never in the YT video title
- Cascade: add top-10 steps (3+4) with duration still active before
dropping duration filter, so "Whispers" (position 6-10) can be found
without falling through to the unfiltered last resort
- search query: strip non-ASCII chars from artist name so "☆NoAki☆"
becomes "NoAki" in the query → cleaner YTM search ranking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single-word filter was too loose: "Echoes of Eternity" produced only
"Eternity" which matched unrelated tracks. Now the top 2 longest words
are used so the filter becomes e.g.
title ~= (?i)Eternity & title ~= (?i)Echoes?
title ~= (?i)Amethyst & title ~= (?i)Flowers?
Both words must appear — drastically reduces false matches.
Trailing-s stays optional (Whispers? matches Whisper/Whispers).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without ¶ms=EgWKAQII..., yt-dlp gets a mixed feed of videos,
channels and playlists alongside tracks — same query that shows the
correct song first in the browser "Titel" tab returned nursery rhyme
videos and unrelated channels. This param is YTM's internal 'Songs'
filter (equivalent to clicking the Titel tab).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- buildTitleKeywords: append '?' to keywords ending in 's' so regex
"Whispers?" matches both "Whisper" (YouTube title) and "Whispers"
(iTunes title) — fixes NoAki Whispers downloading wrong track
- _createNdPlaylist: if artist+title search returns no title match,
retry with title-only query so tracks with special chars in artist
name (e.g. "Kløn") are still found in Navidrome
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
backend: drop unreliable artist *= filter entirely — yt-dlp artist field
is empty for many label-distributed YTM tracks (NoAki, etc.), causing
correct results to be rejected in Steps 1-2 and falling through to Step 3
anyway. Search query already targets the right artist; title keyword +
duration are sufficient. Simplifies cascade from 8→8 steps but with fewer
wasted filter attempts.
frontend: add 3s delay before polling getScanStatus (gives Navidrome time
to actually start the scan), and 5s buffer after scan completes (lets DB
writes settle). Fallback to 30s fixed wait if scan API is unavailable.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>