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>
- 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>
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>
- 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>
backend: replace title *= (case-sensitive) with title ~= (?i) regex so
"MEMORIES" and "Echoes of Eternity" match regardless of YT title casing.
Refactor buildTitleKeyword → buildTitleKeywords returning array.
frontend: trigger Navidrome startScan after download completes and wait
for scan to finish before running _createNdPlaylist, so newly downloaded
tracks are indexed and searchable when the playlist is built.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- artistFilterWord: use longest \w+ run instead of stripping special chars,
fixes artists like N!GHT where stripping gave NGHT (not a substring)
- buildTitleKeyword: new fn extracts most distinctive title word, added as
title *= filter to all cascade steps to block unrelated fallback downloads
- Mix version specificity: Radio/Extended/Club Mix titles also filter on the
qualifier word so plain versions are rejected
- Switch uploader *= to artist *= so label-uploaded tracks (Monstercat etc.)
match by music metadata artist tag, not channel name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code 101 means --max-downloads was reached — the file was downloaded
successfully. Treating it as error caused the ffmpeg/metadata step to
be skipped, leaving files with no tags.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--playlist-items 1-5 --max-downloads 1 on all filtered steps so yt-dlp
skips non-matching results (wrong artist, sped up, wrong duration) and
lands on the first result that actually passes all active filters.
Unfiltered last-resort fallbacks keep --playlist-items 1.
YouTube fallback uses ytsearch5: to match the 5-result window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Snapshot dir before each yt-dlp call so hasAudio() only detects
files added by the current attempt, not pre-existing files from
parallel jobs downloading to the same album directory.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Duration: ±15% (min 15s) instead of -40%/+50%
- Variant filter: reject sped/speedup/slowed/reverb/nightcore/karaoke/
parody/remix/cover/acoustic/instrumental unless original title has keyword
- Cascade: YTM+YT tried at each level (dur+artist+variant → no artist → no dur → no filter)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Strips special chars from artist name (☆NoAki☆ → NoAki) and adds
uploader *= check so wrong-artist results are rejected early.
Falls back to duration-only, then no-filter if artist filter yields nothing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous order let wrong YTM results through on the no-filter fallback.
New order: YTM+filter → YT+filter → YTM no filter → YT no filter.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ytmsearch1: is not a valid yt-dlp scheme — use full music.youtube.com URL with --playlist-items 1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>