Export a Spotify playlist to CSV or text

Save any Spotify playlist or album to a spreadsheet: title, artists, album, the date each song was added, duration and link. Free, in your browser, and you never log in to a third-party site.

Works on Spotify for the web (open.spotify.com) on Windows, Mac, Linux and Chromebook. Open the playlist, then run the script.

export-spotify-playlist.js · 145 linespaste in the DevTools console on open.spotify.com
// 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.

Why it's safe to runAutomated check passed Oct 23, 2026 · tested on a real account Oct 23, 2026
  • 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
  1. Read it: every line is above, with comments.
  2. Search the code for fetch, XMLHttpRequest, cookie, localStorage or eval. None of them appear.
  3. Open the Network tab before running it: you'll only see requests to open.spotify.com.
Did it work for you?

How to use it

  1. Open the playlist on open.spotify.com. Public playlists and albums work without signing in.
  2. Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If Chrome asks, type allow pasting and press Enter.
  3. Copy the script, paste it in the console and press Enter. It scrolls through the list, then offers the file.

What you get

One row per song with its position, title, every artist, album, the date it was added to the playlist (as Spotify shows it), duration and the song’s Spotify link. Choose TXT instead to get a plain “Artist – Title” list that playlist converters and search boxes understand.

Why not an online exporter?

Most export sites ask you to log in with Spotify and give them access to your account. This script needs no permission at all: it reads the playlist already on your screen, in your own browser.

FAQ

Doesn't Spotify have an export button?

No. The apps let you copy songs one by one, but there is no way to save a whole playlist as a file. This script reads the list you already have open.

Do I need to log in or connect my account?

Not for public playlists and albums: they open without signing in. Unlike most online exporters, you never authorize anything, so no app gets access to your account.

Can I move the playlist to Apple Music, YouTube Music or Tidal?

Yes. Export it as CSV and import the file with a playlist converter, or use the TXT format ("Artist - Title" per line) and paste it into one.

Does it work with long playlists?

Yes. Spotify only keeps a few rows on screen, so the script scrolls and collects them all. We tested it on 150-song playlists; it takes a few seconds.

More Spotify tools