Download your whole Liked Songs list as a spreadsheet, with the date you liked each song. Spotify can’t share or export that list; this free script saves it from your own browser.
Sign in on open.spotify.com and open Liked Songs (the heart playlist), then run the script.
// Responsive Muse · Spotify Playlist Exporter · v1.0 · 2026-10-23
// Saves any Spotify playlist, album or your Liked Songs that you can open on open.spotify.com
// to CSV or TXT: title, artists, album, date added, duration and link. Useful to back up a
// playlist or move it to Apple Music, YouTube Music or Tidal. Long playlists load while the
// script scrolls. It makes no network requests of its own and never reads cookies,
// passwords or tokens.
(async () => {
const TOOL = 'Export Spotify playlist';
const FORMAT = 'csv'; // 'csv' (Excel, Sheets, playlist converters) or 'txt' ("Artist - Title", one per line)
if (location.hostname !== 'open.spotify.com' || !/\/(playlist|album|collection\/tracks)\b/.test(location.pathname)) {
alert('Open a playlist, an album or your Liked Songs on Spotify for the web first, for example:\nhttps://open.spotify.com/collection/tracks');
return;
}
// A small box on the page shows progress and asks before changing anything,
// so you never need to read or edit the console.
const many = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; // 1 video, 2 videos
const panel = (() => {
const box = document.createElement('div');
box.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483647;width:340px;max-width:calc(100vw - 32px);' +
'background:#111318;color:#e6e8ee;font:14px/1.5 system-ui,sans-serif;border-radius:12px;padding:16px;' +
'box-shadow:0 8px 30px rgba(0,0,0,.4);text-align:left';
const head = document.createElement('strong');
head.textContent = TOOL;
const msg = document.createElement('p');
msg.style.cssText = 'margin:8px 0 12px;white-space:pre-line';
const row = document.createElement('div');
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end';
box.append(head, msg, row);
document.body.append(box);
const button = (label, primary, onClick) => {
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = 'font:600 14px system-ui,sans-serif;padding:8px 14px;border-radius:8px;cursor:pointer;' +
(primary ? 'background:#0f766e;color:#fff;border:1px solid #0f766e' : 'background:none;color:#e6e8ee;border:1px solid #3a3f4b');
b.onclick = onClick;
row.append(b);
};
return {
text(t) { msg.textContent = t; },
ask(t, yes) {
msg.textContent = t;
row.replaceChildren();
return new Promise(r => {
button('Cancel', false, () => { row.replaceChildren(); r(false); });
button(yes, true, () => { row.replaceChildren(); r(true); });
});
},
stoppable(onStop) { row.replaceChildren(); button('Stop', false, onStop); },
actions(t, list) {
msg.textContent = t;
row.replaceChildren();
list.forEach(([label, primary, run]) => button(label, primary, run));
},
finish(t) { this.actions(t, [['Close', true, () => box.remove()]]); },
close() { box.remove(); },
};
})();
// Offer the result as a downloaded file or as clipboard text (a real click, so it always works)
const deliver = (summary, body, file) => {
const save = () => {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([/\.csv$/.test(file) ? '\ufeff' + body : body], { type: 'text/plain;charset=utf-8' }));
a.download = file;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
panel.text(`${summary}\nSaved as ${file} (check your Downloads).`);
};
const copy = () => navigator.clipboard.writeText(body)
.then(() => panel.text(`${summary}\nCopied. Paste it into a spreadsheet or a note.`))
.catch(() => panel.text(`${summary}\nThe browser blocked the clipboard: use Download instead.`));
panel.actions(summary, [['Close', false, () => panel.close()], ['Copy', false, copy], ['Download', true, save]]);
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
const tracks = new Map();
const clean = s => (s || '').replace(/\s+/g, ' ').trim();
// Links on the page carry the language (/intl-es/track/…): keep only the ID
const link = href => href ? 'https://open.spotify.com/' + href.replace(/^.*?\/(track|episode)\//, '$1/').split('?')[0] : '';
// Spotify scrolls an inner panel, not the window
const first = document.querySelector('[data-testid="tracklist-row"]');
let scroller = first;
while (scroller && !(scroller.scrollHeight > scroller.clientHeight + 50 && /auto|scroll/.test(getComputedStyle(scroller).overflowY))) {
scroller = scroller.parentElement;
}
scroller = scroller || document.scrollingElement;
const grid = first?.closest('[aria-rowcount]'); // the playlist's own list (the sidebar library is another grid)
const expected = grid ? Number(grid.getAttribute('aria-rowcount')) - 1 : 0; // minus the header row
const heading = clean(document.querySelector('[data-testid="entityTitle"]')?.textContent);
const pageAlbum = /\/album\//.test(location.pathname) ? heading : ''; // album pages don't link the album in each row
// Read the songs currently on screen (rows are recycled while scrolling: key = row number)
const collect = () => {
for (const row of document.querySelectorAll('[data-testid="tracklist-row"]')) {
const n = Number(row.closest('[aria-rowindex]')?.getAttribute('aria-rowindex')) - 1;
const a = row.querySelector('a[href*="/track/"], a[href*="/episode/"]');
if (!a || !(n > 0) || tracks.has(n)) continue;
const cells = [...row.querySelectorAll('[role="gridcell"]')];
const added = cells.find(c => !c.querySelector('a, button, img') && !/^\d+$|^\d+:\d\d(:\d\d)?$/.test(clean(c.textContent)));
const duration = cells.map(c => clean(c.textContent)).reverse().find(t => /^\d+:\d\d(:\d\d)?$/.test(t)) || '';
tracks.set(n, {
n,
title: clean(a.textContent),
artist: [...row.querySelectorAll('a[href*="/artist/"], a[href*="/show/"]')].map(x => clean(x.textContent)).join(', '),
album: clean(row.querySelector('a[href*="/album/"]')?.textContent) || pageAlbum,
added: clean(added?.textContent),
duration,
url: link(a.getAttribute('href')),
});
}
};
// 1. Scroll to the end, collecting as we go, until nothing new appears three times in a row
let stop = false;
panel.stoppable(() => { stop = true; });
scroller.scrollTo(0, 0);
await sleep(800);
let idle = 0;
while (idle < 3 && !stop && !(expected && tracks.size >= expected)) {
const before = tracks.size;
collect();
scroller.scrollBy(0, scroller.clientHeight * 0.8);
await sleep(900);
collect();
idle = tracks.size === before ? idle + 1 : 0;
panel.text(`Collecting songs… ${tracks.size}${expected ? ' of ' + expected : ''}`);
}
const list = [...tracks.values()].sort((x, y) => x.n - y.n);
if (!list.length) {
panel.finish('No songs found. Spotify may have changed its layout: please report it on responsive-muse.com.');
return 0;
}
// 2. Build the file and offer it
const cell = v => '"' + String(v).replace(/"/g, '""') + '"';
const body = FORMAT === 'txt'
? list.map(t => `${t.artist} - ${t.title}`).join('\n')
: ['#,Title,Artist,Album,Date added,Duration,URL', ...list.map(t =>
[t.n, t.title, t.artist, t.album, t.added, t.duration, t.url].map(cell).join(','))].join('\n');
const missing = expected && list.length < expected ? ` (the playlist says ${expected}; scroll down and run it again if some are missing)` : '';
const name = (heading || clean(document.title.split(' | Spotify')[0]) || 'playlist').replace(/[\\/:*?"<>|]+/g, '_');
deliver(`${stop ? 'Stopped early. ' : ''}Found ${many(list.length, 'song')}${missing}.`, body, `${name}.${FORMAT}`);
return list.length;
})();
Bookmarklet: drag the middle button to your bookmarks bar, open open.spotify.com and click the bookmark. It runs the same script with the options above.
- Sends nothing anywhere. No fetch, XHR or external scripts. The only network activity is open.spotify.com itself loading your list.
- Never touches your login. It doesn't read cookies, passwords, tokens or browser storage.
- Only runs on open.spotify.com. Anywhere else it stops before doing anything.
- Short and readable. 145 commented lines, not minified or obfuscated.
- Shows its progress on the page. A small box tells you what it's doing and offers Download or Copy at the end.
- Same code for everyone. SHA-256 of the default version:
c72cbb7ea15f4554…
Verify it yourself in 30 seconds
- Read it: every line is above, with comments.
- Search the code for
fetch,XMLHttpRequest,cookie,localStorageoreval. None of them appear. - Open the Network tab before running it: you'll only see requests to open.spotify.com.
How to use it
- Sign in on open.spotify.com/collection/tracks (that is Liked Songs).
- Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If Chrome asks, type
allow pastingand press Enter. - Copy the script, paste it in the console and press Enter. It scrolls through the whole list, then offers the file.
What you get
Every liked song with title, artists, album, the date you liked it, duration and link, in the same order as in Spotify (newest first). Keep it as a backup, sort it by date to see what you were listening to, or import it into another service with a playlist converter.
FAQ
Why export my Liked Songs?
It is the list most people care about and the one that is hardest to move: Spotify can't share it like a normal playlist. A CSV keeps a copy with the date you liked each song.
Is it safe?
The script runs in your own Spotify tab, sends nothing anywhere and never touches your password. You don't give any app access to your account, unlike export sites that ask you to log in with Spotify.
How long does it take?
About a second for every 15 songs while it scrolls. You can press Stop at any time and keep what it has collected.
