Skip to content

Instantly share code, notes, and snippets.

@tayiorbeii
Created April 25, 2026 17:21
Show Gist options
  • Select an option

  • Save tayiorbeii/2a0ff711fee223be8e4f76e28a2213f3 to your computer and use it in GitHub Desktop.

Select an option

Save tayiorbeii/2a0ff711fee223be8e4f76e28a2213f3 to your computer and use it in GitHub Desktop.
Userscript for downloading a txt file of a Spotify playlist
// ==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