Created
April 25, 2026 17:21
-
-
Save tayiorbeii/2a0ff711fee223be8e4f76e28a2213f3 to your computer and use it in GitHub Desktop.
Userscript for downloading a txt file of a Spotify playlist
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Spotify Tracklist Downloader (v14 - Header Fix) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 14.0 | |
| // @description The definitive script. Correctly handles API headers to capture data without errors. Fast, reliable, and complete. | |
| // @author Gemini & taylorbell | |
| // @match https://open.spotify.com/* | |
| // @grant unsafeWindow | |
| // @grant GM_addStyle | |
| // @run-at document-start | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=spotify.com | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // --- 1. GLOBAL STORE & IMMEDIATE, SAFE INTERCEPTION --- | |
| const capturedState = { | |
| headers: null, | |
| hash: null, | |
| }; | |
| const API_URL = "https://api-partner.spotify.com/pathfinder/v2/query"; | |
| const originalFetch = unsafeWindow.fetch; | |
| // This runs immediately to ensure we don't miss any requests. | |
| unsafeWindow.fetch = async (...args) => { | |
| const [url, config] = args; | |
| const responsePromise = originalFetch(...args); // Pass through immediately | |
| try { | |
| if (typeof url === 'string' && url.startsWith(API_URL) && config?.headers) { | |
| // CORRECTED HEADER HANDLING: Access as a plain object, not a Headers object. | |
| const auth = config.headers.authorization; | |
| const token = config.headers['client-token']; | |
| if (auth && token) { | |
| capturedState.headers = { | |
| 'authorization': auth, | |
| 'client-token': token, | |
| 'content-type': 'application/json;charset=UTF-8', | |
| }; | |
| } | |
| if (config.body && typeof config.body === 'string' && config.body.includes('fetchPlaylistContents')) { | |
| const bodyData = JSON.parse(config.body); | |
| const hash = bodyData?.extensions?.persistedQuery?.sha256Hash; | |
| if (hash && capturedState.hash !== hash) { | |
| capturedState.hash = hash; | |
| console.log(`[Spotify Downloader] PlaylistContents HASH captured: ${hash}`); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error('[Spotify Downloader] Error during passive capture:', e); | |
| } | |
| return responsePromise; | |
| }; | |
| // --- 2. MAIN DOWNLOAD LOGIC --- | |
| async function downloadTracklist(button) { | |
| if (!capturedState.headers || !capturedState.hash) { | |
| alert('Could not capture necessary API details. This can happen on the first load.\n\nPlease do a hard refresh (Ctrl+F5) and try again.'); | |
| return; | |
| } | |
| button.disabled = true; | |
| try { | |
| const playlistElement = document.querySelector('[data-testid="playlist-tracklist"]'); | |
| const totalTracks = parseInt(playlistElement.getAttribute('aria-rowcount'), 10) - 1; | |
| const playlistName = playlistElement.getAttribute('aria-label').trim(); | |
| const playlistUri = `spotify:playlist:${window.location.pathname.split('/')[2]}`; | |
| if (!totalTracks || !playlistName || !playlistUri) { | |
| throw new Error("Could not parse playlist info from the page. Is this a valid playlist?"); | |
| } | |
| const allTracks = []; | |
| const limit = 100; | |
| const numPages = Math.ceil(totalTracks / limit); | |
| for (let i = 0; i < numPages; i++) { | |
| const offset = i * limit; | |
| button.textContent = `Fetching... (${offset}/${totalTracks})`; | |
| const body = { | |
| variables: { uri: playlistUri, offset, limit }, | |
| operationName: "fetchPlaylistContents", | |
| extensions: { persistedQuery: { version: 1, sha256Hash: capturedState.hash } } | |
| }; | |
| const response = await originalFetch(API_URL, { | |
| method: 'POST', | |
| headers: capturedState.headers, // Use the captured plain object | |
| body: JSON.stringify(body) | |
| }); | |
| if (!response.ok) throw new Error(`API request failed with status ${response.status}`); | |
| const data = await response.json(); | |
| const items = data?.data?.playlistV2?.content?.items; | |
| if (!items) throw new Error("API response was missing track items."); | |
| items.forEach(item => { | |
| const trackData = item?.itemV2?.data; | |
| if (trackData) { | |
| const artists = (trackData.artists.items || []).map(a => a.profile.name).join(', '); | |
| const title = trackData.name; | |
| const album = trackData.albumOfTrack.name; | |
| allTracks.push(`${artists} - ${title} - ${album}`); | |
| } | |
| }); | |
| } | |
| button.textContent = 'Download Complete'; | |
| alert(`Successfully fetched all ${allTracks.length} tracks!`); | |
| const fileContent = allTracks.join('\n'); | |
| const pageTitle = playlistName.replace(/[^a-z0-9]/gi, '_').replace(/_+/g, '_'); | |
| const blob = new Blob([fileContent], { type: 'text/plain;charset=utf-8' }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = `${pageTitle}.txt`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(a.href); | |
| } catch (error) { | |
| console.error('[Spotify Downloader] A critical error occurred:', error); | |
| alert(`An error occurred: ${error.message}\n\nPlease check the console (F12) for more details.`); | |
| } finally { | |
| button.disabled = false; | |
| setTimeout(() => { button.textContent = 'Download TXT'; }, 2000); | |
| } | |
| } | |
| // --- 3. INDEPENDENT BUTTON INJECTION LOGIC --- | |
| window.addEventListener('load', function() { | |
| GM_addStyle(` | |
| #download-tracklist-button { | |
| background-color: #1DB954; color: white; border: none; padding: 8px 18px; | |
| border-radius: 500px; font-size: 14px; font-weight: 700; cursor: pointer; | |
| margin-left: 16px; height: 48px; display: inline-flex; align-items: center; | |
| gap: 8px; white-space: nowrap; | |
| } | |
| #download-tracklist-button:hover { background-color: #1ED760; transform: scale(1.04); } | |
| #download-tracklist-button:disabled { background-color: #535353; cursor: not-allowed; transform: none; } | |
| `); | |
| let injectionInterval = setInterval(() => { | |
| if (document.getElementById('download-tracklist-button')) return; | |
| const actionBar = document.querySelector('div[data-testid="action-bar-row"]'); | |
| const moreButton = actionBar?.querySelector('button[data-testid="more-button"]'); | |
| if (moreButton) { | |
| const downloadBtn = document.createElement('button'); | |
| downloadBtn.innerHTML = 'Download TXT'; | |
| downloadBtn.id = 'download-tracklist-button'; | |
| downloadBtn.onclick = function() { downloadTracklist(this); }; | |
| moreButton.parentNode.insertBefore(downloadBtn, moreButton.nextSibling); | |
| console.log('[Spotify Downloader] Download button injected.'); | |
| } | |
| }, 1000); | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment