Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ As many users have noted, there is no way to export/archive/backup playlists fro

- ⚙️ Optional inclusion of album, artist and audio features data in export files
- 🔍 Playlist search with [advanced search syntax](#advanced-search-syntax) and results export
- 🔗 Share playlist snapshots by link, viewable without a Spotify login
- 💿 Saved albums export, one line per album
- 🌓 Dark mode
- 🗺 Available in 15 languages (English, French, Spanish, Italian, German, Portuguese, Swedish, Dutch, Greek, Turkish, Japanese, Arabic, Simplified Chinese, Traditional Chinese and Cantonese)
Expand All @@ -31,6 +32,18 @@ Click 'Export All' to save a zip file containing a CSV file for each playlist in

Liked Songs and saved albums appear as the first rows of the table. 'Export All' includes them, with your saved albums as `saved_albums.csv`.

### Sharing a Playlist Snapshot

Click **Share** next to a playlist or Liked Songs, then **Create share link** and **Copy link**. Anyone with the complete link can view the track list and download a CSV without logging in to Spotify. This also shares the contents of private playlists, so only send the link to people you want to see them. Saved albums are still exported as CSV only.

A snapshot includes the playlist name and each track's Spotify URI, name, artists and album, preserving order and duplicates. Unavailable (null) tracks are skipped, as in CSV exports; local tracks remain visible without a Spotify playback link. The snapshot does not update when the original playlist changes. It excludes account information, access tokens and optional extended export metadata.

The entire snapshot is compressed into the URL fragment (`#playlist=…`), with no storage service or Spotify write permissions. Links use the current hosting path and work on static hosting such as GitHub Pages. Opening one makes no Spotify API requests. Playlists of 1,000 and 2,000 tracks are covered by automated round-trip tests.

Before DEFLATE compression, the compact version 2 format stores repeated artist and album names in a shared dictionary, groups track fields into columns and abbreviates Spotify track/episode URI prefixes. Order, duplicate tracks and local track metadata are restored exactly. The encoder compares the compressed compact and original formats and uses the shorter one. Existing version 1 links remain readable. Both the packed data and the restored snapshot have size limits.

Links are long: their size depends on the metadata, not just the number of tracks. There is a 512,000-character link limit, a 2,000,000-character decoded snapshot limit and a 10,000-track format limit. Some messaging apps may truncate long URLs; share a CSV if the recipient cannot open the full link. A short link for large snapshots would require an external storage service.

### Re-importing Playlists

Once playlists are saved, it's also pretty straightforward to re-import them into Spotify. Open up the CSV file in Excel, for example, select and copy the `spotify:track:xxx` URIs, then simply create a playlist in Spotify and paste them in. This has only been tested with the desktop app.
Expand Down
42 changes: 41 additions & 1 deletion src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@
position: relative;
}

.playlist-share {
max-width: 42rem;
}

.playlist-share-action.btn-outline-secondary {
--bs-btn-color: var(--bs-body-color);
}

.playlist-share-primary.btn-primary {
--bs-btn-bg: #357a35;
--bs-btn-border-color: #357a35;
--bs-btn-hover-bg: #2d682d;
--bs-btn-hover-border-color: #2d682d;
--bs-btn-active-bg: #2d682d;
--bs-btn-active-border-color: #2d682d;
--bs-btn-disabled-bg: #357a35;
--bs-btn-disabled-border-color: #357a35;
}

.playlist-share-action:focus-visible,
.playlist-share-primary:focus-visible {
outline: 2px solid var(--bs-emphasis-color);
outline-offset: 2px;
}

.shared-playlist-heading {
min-width: 0;
overflow-wrap: anywhere;
}

.shared-playlist-table {
table-layout: fixed;
min-width: 32rem;
overflow-wrap: anywhere;

th:first-child {
width: 4rem;
}
}

#loginError {
text-align: center;
}
Expand Down Expand Up @@ -77,7 +117,7 @@
}

&.export {
width: 100px;
width: 170px;
}
}
}
Expand Down
27 changes: 26 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import Login from 'components/Login'
import PlaylistTable from "components/PlaylistTable"
import TopMenu from "components/TopMenu"
import SharedPlaylist from "components/SharedPlaylist"
import { isPlaylistShare } from "./playlistShare"
import { loadAccessToken, exchangeCodeForToken, MissingCodeVerifierError, OAuthErrorResponse } from "auth"

const baseUrl = import.meta.env.BASE_URL
Expand Down Expand Up @@ -63,7 +65,7 @@ function removeCallbackParams() {
window.history.replaceState({}, document.title, window.location.pathname + window.location.hash)
}

function App() {
function ExportifyApp() {
const { t } = useTranslation()
const [subtitle, setSubtitle] = useState(<Translation>{(t) => t("tagline")}</Translation>)
const searchParams = new URLSearchParams(window.location.search)
Expand Down Expand Up @@ -129,4 +131,27 @@ function App() {
);
}

function App() {
const { t } = useTranslation()
const [hash, setHash] = useState(window.location.hash)
useEffect(() => {
const onHashChange = () => setHash(window.location.hash)
window.addEventListener("hashchange", onHashChange)
return () => window.removeEventListener("hashchange", onHashChange)
}, [])

if (!isPlaylistShare(hash)) return <ExportifyApp />

// A snapshot never initializes Spotify login or the account's playlist table.
return <div className="App container">
<header className="App-header">
<div className="d-sm-none d-block mb-5" />
<TopMenu loggedIn={false} />
<h1><FontAwesomeIcon icon={['fab', 'spotify']} color="#84BD00" size="sm" /> <a href={baseUrl}>Exportify</a></h1>
<p className="lead text-body-secondary">{t("share.tagline")}</p>
</header>
<SharedPlaylist key={hash} hash={hash} />
</div>
}

export default App;
20 changes: 14 additions & 6 deletions src/components/PlaylistRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"

import PlaylistExporter from "./PlaylistExporter"
import SavedAlbumsExporter from "./SavedAlbumsExporter"
import PlaylistShare from "./PlaylistShare"
import { ExportablePlaylist, PlaylistPlaceholder } from "./data/PlaylistsData"
import { ExportConfig } from "./ConfigDropdown"

Expand All @@ -16,7 +17,8 @@ interface PlaylistRowProps extends WithTranslation {

class PlaylistRow extends React.Component<PlaylistRowProps> {
state = {
exporting: false
exporting: false,
sharing: false
}

exportPlaylist = (playlist: ExportablePlaylist) => {
Expand Down Expand Up @@ -65,7 +67,7 @@ class PlaylistRow extends React.Component<PlaylistRowProps> {
</tr >
);

return (
return (<>
<tr>
<td>{this.renderIcon(playlist)}</td>
<td><a href={playlist.uri}>{playlist.name}</a></td>
Expand All @@ -74,12 +76,18 @@ class PlaylistRow extends React.Component<PlaylistRowProps> {
<td className="d-none d-sm-table-cell">{this.renderTickCross(playlist.public)}</td>
<td className="d-none d-md-table-cell">{this.renderTickCross(playlist.collaborative)}</td>
<td className="text-end">
<Button type="submit" variant="primary" onClick={() => this.exportPlaylist(playlist)} disabled={this.state.exporting} className="text-nowrap btn-xs">
<FontAwesomeIcon icon={['fas', this.state.exporting ? 'sync' : 'download']} size="sm" spin={this.state.exporting} /> {this.props.i18n.t("playlist.export")}
</Button>
<div className="d-flex flex-wrap justify-content-end gap-1">
{!("albums" in playlist) && <Button type="button" variant="outline-secondary" className="text-nowrap btn-xs playlist-share-action" aria-expanded={this.state.sharing} aria-controls={`share-${playlist.id}`} onClick={() => this.setState({ sharing: !this.state.sharing })}>
<FontAwesomeIcon icon={['fas', 'link']} size="sm" /> {this.props.i18n.t("share.action")}
</Button>}
<Button type="submit" variant="primary" onClick={() => this.exportPlaylist(playlist)} disabled={this.state.exporting} className="text-nowrap btn-xs">
<FontAwesomeIcon icon={['fas', this.state.exporting ? 'sync' : 'download']} size="sm" spin={this.state.exporting} /> {this.props.i18n.t("playlist.export")}
</Button>
</div>
</td>
</tr>
);
{this.state.sharing && !("albums" in playlist) && <tr><td colSpan={7}><PlaylistShare playlist={playlist} onApiError={this.props.onApiError} /></td></tr>}
</>);
}
}

Expand Down
104 changes: 104 additions & 0 deletions src/components/PlaylistShare.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import React from "react"
import { render, screen, waitFor, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import FileSaver from "file-saver"
import JSZip from "jszip"
import { http, HttpResponse } from "msw"
import App from "../App"
import PlaylistTable from "./PlaylistTable"
import { decodePlaylistSnapshot, PlaylistSnapshot } from "../playlistShare"
import { ghostpoetTracksUrl, handlerCalled } from "../mocks/handlers"
import { server } from "../mocks/server"
import i18n from "../i18n/config"
import { csvRecords } from "../test/csv"

vi.mock('@bugsnag/js')

beforeEach(async () => {
await i18n.changeLanguage('en')
localStorage.setItem('access_token', 'TEST_ACCESS_TOKEN')
})

async function openShare(user: ReturnType<typeof userEvent.setup>) {
render(<PlaylistTable onSetSubtitle={vi.fn()} />)
const row = await screen.findByRole('row', { name: /Ghostpoet/ })
await user.click(within(row).getByRole('button', { name: 'Share' }))
}

test('creates a share only after explicit confirmation, then copies the complete link', async () => {
const user = userEvent.setup()
await openShare(user)
expect(screen.getByText(/including private playlists and Liked Songs/)).toBeInTheDocument()
expect(handlerCalled.mock.calls.map(([url]) => url)).not.toContain(expect.stringContaining('/tracks?offset='))

await user.click(screen.getByRole('button', { name: 'Create share link' }))
const input = await screen.findByRole('textbox', { name: 'Playlist share link' })
const link = (input as HTMLInputElement).value
expect((await decodePlaylistSnapshot(new URL(link).hash)).tracks).toHaveLength(2)
await user.click(screen.getByRole('button', { name: 'Copy link' }))
expect(await navigator.clipboard.readText()).toBe(link)
expect(await screen.findByRole('status')).toHaveTextContent('Link copied.')
})

test('keeps the link available for manual copying if clipboard access is denied', async () => {
const user = userEvent.setup()
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('Permission denied'))
await openShare(user)
await user.click(screen.getByRole('button', { name: 'Create share link' }))
await screen.findByRole('textbox', { name: 'Playlist share link' })
await user.click(screen.getByRole('button', { name: 'Copy link' }))

expect(await screen.findByRole('status')).toHaveTextContent('Select the link and copy it manually.')
expect(screen.getByRole('textbox', { name: 'Playlist share link' })).toHaveAttribute('readonly')
})

test('allows retry after Spotify refuses snapshot creation', async () => {
const user = userEvent.setup()
server.use(http.get(ghostpoetTracksUrl, () => HttpResponse.json({ error: { status: 403, message: 'Refused' } }, { status: 403 })))
await openShare(user)
await user.click(screen.getByRole('button', { name: 'Create share link' }))

expect(await screen.findByText('Could not create the link. Please try again.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create share link' })).toBeEnabled()
expect(screen.queryByRole('textbox', { name: 'Playlist share link' })).not.toBeInTheDocument()
})

const snapshot: PlaylistSnapshot = {
version: 1,
name: 'Shared 🎶 playlist',
tracks: [['spotify:track:1GrLfs4TEvAZ86HVzXHchS', 'Crying "again"\nNow', 'Six by Seven', 'Best of Six By Seven']],
}

test.each([
{ loggedIn: false, format: 'original' }, { loggedIn: true, format: 'original' },
{ loggedIn: false, format: 'compact' }, { loggedIn: true, format: 'compact' },
])('opens and downloads a $format snapshot without Spotify requests (logged in: $loggedIn)', async ({ loggedIn, format }) => {
const user = userEvent.setup()
if (!loggedIn) localStorage.clear()
const [uri, name, artist, album] = snapshot.tracks[0]
const data = format === 'original' ? snapshot : [2, snapshot.name, [artist, album], ['t' + uri.split(':')[2]], [name], [0], [1]]
const encoded = await new JSZip().file('playlist.json', JSON.stringify(data)).generateAsync({ type: 'base64', compression: 'DEFLATE' })
const link = '#playlist=' + encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
window.history.replaceState(null, '', link)
const save = vi.spyOn(FileSaver, 'saveAs').mockImplementation(() => {})
render(<App />)

expect(await screen.findByRole('heading', { name: snapshot.name })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Get Started/ })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: /Crying/ })).toHaveAttribute('href', 'https://open.spotify.com/track/1GrLfs4TEvAZ86HVzXHchS')
await user.click(screen.getByRole('button', { name: 'Download CSV' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(1))
expect(csvRecords(await (save.mock.calls[0][0] as Blob).text())).toEqual([{
'Track URI': snapshot.tracks[0][0], 'Track Name': snapshot.tracks[0][1], 'Artist Name(s)': snapshot.tracks[0][2], 'Album Name': snapshot.tracks[0][3],
}])
expect(handlerCalled).not.toHaveBeenCalled()
})

test('shows a useful error for a truncated link without trying Spotify login', async () => {
window.history.replaceState(null, '', '#playlist=truncated')
render(<App />)

expect(await screen.findByRole('alert')).toHaveTextContent('Ask the sender to copy the full link again.')
expect(screen.getByRole('link', { name: 'Go to Exportify' })).toHaveAttribute('href', '/')
expect(handlerCalled).not.toHaveBeenCalled()
})
60 changes: 60 additions & 0 deletions src/components/PlaylistShare.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React, { useState } from "react"
import { Alert, Button, Form, InputGroup } from "react-bootstrap"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { useTranslation } from "react-i18next"
import { TracksPlaylist } from "./data/PlaylistsData"
import { createPlaylistShareLink, ShareLinkTooLargeError } from "../playlistShare"

export default function PlaylistShare({ playlist, onApiError }: { playlist: TracksPlaylist, onApiError: (error: unknown) => void }) {
const { t } = useTranslation()
const [creating, setCreating] = useState(false)
const [link, setLink] = useState("")
const [error, setError] = useState("")
const [copied, setCopied] = useState(false)
const [copyFailed, setCopyFailed] = useState(false)

const create = async () => {
setCreating(true)
setError("")
try {
setLink(await createPlaylistShareLink(playlist))
} catch (error) {
if (error instanceof ShareLinkTooLargeError) {
setError("share.too_large")
} else {
setError("share.failed")
onApiError(error)
}
} finally {
setCreating(false)
}
}

const copy = async () => {
try {
await navigator.clipboard.writeText(link)
setCopied(true)
setCopyFailed(false)
} catch {
setCopyFailed(true)
}
}

const inputId = `share-link-${playlist.id}`
return <div id={`share-${playlist.id}`} className="playlist-share text-start py-2">
<p className="small mb-2">{t("share.description")}</p>
{!link && <Button type="button" variant="primary" className="btn-xs playlist-share-primary" disabled={creating} onClick={create}>
{creating && <FontAwesomeIcon icon={['fas', 'sync']} spin className="me-1" />}
{t(creating ? "share.creating" : "share.create")}
</Button>}
{error && <Alert variant="danger" className="small mt-2 mb-0">{t(error)}</Alert>}
{link && <>
<Form.Label htmlFor={inputId} className="small">{t("share.link")}</Form.Label>
<InputGroup size="sm">
<Form.Control id={inputId} value={link} readOnly onFocus={event => event.target.select()} />
<Button type="button" variant="outline-secondary" className="playlist-share-action" onClick={copy}>{t("share.copy")}</Button>
</InputGroup>
<p className="small mt-2 mb-0" role="status">{t(copyFailed ? "share.copy_failed" : copied ? "share.copied" : "share.long_link")}</p>
</>}
</div>
}
49 changes: 49 additions & 0 deletions src/components/SharedPlaylist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import React, { useEffect, useState } from "react"
import { Alert, Button } from "react-bootstrap"
import { useTranslation } from "react-i18next"
import { saveAs } from "file-saver"
import { decodePlaylistSnapshot, PlaylistSnapshot, sharedTrackUrl } from "../playlistShare"

export default function SharedPlaylist({ hash }: { hash: string }) {
const { t } = useTranslation()
const [result, setResult] = useState<{ snapshot?: PlaylistSnapshot, failed?: boolean }>({})

useEffect(() => {
let active = true
decodePlaylistSnapshot(hash).then(
snapshot => { if (active) setResult({ snapshot }) },
() => { if (active) setResult({ failed: true }) },
)
return () => { active = false }
}, [hash])

if (result.failed) return <Alert variant="danger">{t("share.invalid")} <a href={import.meta.env.BASE_URL}>{t("share.home")}</a></Alert>
if (!result.snapshot) return <p role="status">{t("share.loading")}</p>

const snapshot = result.snapshot
const exportCsv = () => {
const rows = [[t("track.track_uri"), t("track.track_name"), t("track.artist_names"), t("track.album_name")], ...snapshot.tracks]
const csv = rows.map(row => row.map(value => `"${value.replace(/"/g, '""')}"`).join(",")).join("\n") + "\n"
saveAs(new Blob([csv], { type: "text/csv;charset=utf-8" }), "shared_playlist.csv", { autoBom: false })
}

return <section aria-labelledby="shared-playlist-name">
<div className="d-flex flex-wrap align-items-start justify-content-between gap-2 mb-3">
<div className="shared-playlist-heading">
<h2 id="shared-playlist-name">{snapshot.name}</h2>
<p className="text-body-secondary mb-0">{t("share.snapshot", { count: snapshot.tracks.length })}</p>
</div>
<Button type="button" variant="primary" className="playlist-share-primary" onClick={exportCsv}>{t("share.download")}</Button>
</div>
<p className="small text-body-secondary">{t("share.snapshot_description")}</p>
{snapshot.tracks.length === 0 ? <p>{t("share.empty")}</p> : <div className="table-responsive">
<table className="table table-hover table-sm shared-playlist-table">
<thead><tr><th scope="col">#</th><th scope="col">{t("track.track_name")}</th><th scope="col">{t("track.artist_names")}</th><th scope="col">{t("track.album_name")}</th></tr></thead>
<tbody>{snapshot.tracks.map(([uri, name, artists, album], index) => {
const url = sharedTrackUrl(uri)
return <tr key={index}><td>{index + 1}</td><td>{url ? <a href={url} target="_blank" rel="noreferrer">{name}</a> : name}</td><td>{artists}</td><td>{album}</td></tr>
})}</tbody>
</table>
</div>}
</section>
}
Loading