From 09ac8c3036c1b687f5301c13df4d1954bd9451d0 Mon Sep 17 00:00:00 2001 From: dev-x64 <18104302+dev-x64@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:12:51 +0200 Subject: [PATCH 1/2] Add shareable playlist snapshots without Spotify login --- README.md | 11 ++ src/App.scss | 42 ++++++- src/App.tsx | 27 +++- src/components/PlaylistRow.tsx | 20 ++- src/components/PlaylistShare.test.tsx | 97 ++++++++++++++ src/components/PlaylistShare.tsx | 60 +++++++++ src/components/SharedPlaylist.tsx | 49 ++++++++ .../__snapshots__/PlaylistTable.test.tsx.snap | 118 +++++++++++++----- src/i18n/locales.test.ts | 2 +- src/i18n/locales/en/translation.json | 22 ++++ src/icons.ts | 5 +- src/playlistShare.test.ts | 87 +++++++++++++ src/playlistShare.ts | 92 ++++++++++++++ 13 files changed, 587 insertions(+), 45 deletions(-) create mode 100644 src/components/PlaylistShare.test.tsx create mode 100644 src/components/PlaylistShare.tsx create mode 100644 src/components/SharedPlaylist.tsx create mode 100644 src/playlistShare.test.ts create mode 100644 src/playlistShare.ts diff --git a/README.md b/README.md index b81562b..73896a3 100644 --- a/README.md +++ b/README.md @@ -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) @@ -31,6 +32,16 @@ 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. + +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. diff --git a/src/App.scss b/src/App.scss index 19696a4..70f54e8 100644 --- a/src/App.scss +++ b/src/App.scss @@ -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; } @@ -77,7 +117,7 @@ } &.export { - width: 100px; + width: 170px; } } } diff --git a/src/App.tsx b/src/App.tsx index 0ed344d..210ba81 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 @@ -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({(t) => t("tagline")}) const searchParams = new URLSearchParams(window.location.search) @@ -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 + + // A snapshot never initializes Spotify login or the account's playlist table. + return
+
+
+ +

Exportify

+

{t("share.tagline")}

+
+ +
+} + export default App; diff --git a/src/components/PlaylistRow.tsx b/src/components/PlaylistRow.tsx index 6ec1a54..c155bfd 100644 --- a/src/components/PlaylistRow.tsx +++ b/src/components/PlaylistRow.tsx @@ -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" @@ -16,7 +17,8 @@ interface PlaylistRowProps extends WithTranslation { class PlaylistRow extends React.Component { state = { - exporting: false + exporting: false, + sharing: false } exportPlaylist = (playlist: ExportablePlaylist) => { @@ -65,7 +67,7 @@ class PlaylistRow extends React.Component { ); - return ( + return (<> {this.renderIcon(playlist)} {playlist.name} @@ -74,12 +76,18 @@ class PlaylistRow extends React.Component { {this.renderTickCross(playlist.public)} {this.renderTickCross(playlist.collaborative)} - +
+ {!("albums" in playlist) && } + +
- ); + {this.state.sharing && !("albums" in playlist) && } + ); } } diff --git a/src/components/PlaylistShare.test.tsx b/src/components/PlaylistShare.test.tsx new file mode 100644 index 0000000..ff8f232 --- /dev/null +++ b/src/components/PlaylistShare.test.tsx @@ -0,0 +1,97 @@ +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 { http, HttpResponse } from "msw" +import App from "../App" +import PlaylistTable from "./PlaylistTable" +import { decodePlaylistSnapshot, encodePlaylistSnapshot, 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) { + render() + 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([false, true])('opens and downloads a snapshot without Spotify requests (logged in: %s)', async loggedIn => { + const user = userEvent.setup() + if (!loggedIn) localStorage.clear() + const link = await encodePlaylistSnapshot(snapshot) + window.history.replaceState(null, '', link) + const save = vi.spyOn(FileSaver, 'saveAs').mockImplementation(() => {}) + render() + + 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() + + 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() +}) diff --git a/src/components/PlaylistShare.tsx b/src/components/PlaylistShare.tsx new file mode 100644 index 0000000..f240e75 --- /dev/null +++ b/src/components/PlaylistShare.tsx @@ -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
+

{t("share.description")}

+ {!link && } + {error && {t(error)}} + {link && <> + {t("share.link")} + + event.target.select()} /> + + +

{t(copyFailed ? "share.copy_failed" : copied ? "share.copied" : "share.long_link")}

+ } +
+} diff --git a/src/components/SharedPlaylist.tsx b/src/components/SharedPlaylist.tsx new file mode 100644 index 0000000..81505d4 --- /dev/null +++ b/src/components/SharedPlaylist.tsx @@ -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 {t("share.invalid")} {t("share.home")} + if (!result.snapshot) return

{t("share.loading")}

+ + 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
+
+
+

{snapshot.name}

+

{t("share.snapshot", { count: snapshot.tracks.length })}

+
+ +
+

{t("share.snapshot_description")}

+ {snapshot.tracks.length === 0 ?

{t("share.empty")}

:
+ + + {snapshot.tracks.map(([uri, name, artists, album], index) => { + const url = sharedTrackUrl(uri) + return + })} +
#{t("track.track_name")}{t("track.artist_names")}{t("track.album_name")}
{index + 1}{url ? {name} : name}{artists}{album}
+
} +
+} diff --git a/src/components/__snapshots__/PlaylistTable.test.tsx.snap b/src/components/__snapshots__/PlaylistTable.test.tsx.snap index 04e43e0..896ec8a 100644 --- a/src/components/__snapshots__/PlaylistTable.test.tsx.snap +++ b/src/components/__snapshots__/PlaylistTable.test.tsx.snap @@ -237,25 +237,50 @@ exports[`playlist loading 1`] = ` - + + Share + + + @@ -330,25 +355,50 @@ exports[`playlist loading 1`] = ` - + + Share + + + diff --git a/src/i18n/locales.test.ts b/src/i18n/locales.test.ts index 55cee1a..0a3e819 100644 --- a/src/i18n/locales.test.ts +++ b/src/i18n/locales.test.ts @@ -14,7 +14,7 @@ function flatten(translations: Translations, prefix = ""): Record (text.match(/{{\s*\w+\s*}}/g) ?? []).map(placeholder => placeholder.replace(/[{}\s]/g, "")).sort() // Keys under these prefixes fall back to English until someone translates them -const englishOnly = ["api_error."] +const englishOnly = ["api_error.", "share."] const translatable = (key: string) => !englishOnly.some(prefix => key.startsWith(prefix)) // Languages need different plural forms, so keys are compared without their plural suffix diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index f6d6a14..ae6cd36 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -109,5 +109,27 @@ "tempo": "Tempo", "time_signature": "Time Signature" } + }, + "share": { + "action": "Share", + "description": "Create a snapshot anyone with the link can view, including private playlists and Liked Songs. No Spotify login is needed. The snapshot contains track names, artists, albums and Spotify track URIs.", + "create": "Create share link", + "creating": "Creating link…", + "link": "Playlist share link", + "copy": "Copy link", + "copied": "Link copied.", + "copy_failed": "Could not copy automatically. Select the link and copy it manually.", + "long_link": "This link contains the whole snapshot. Some messaging apps may truncate long links; if that happens, share a CSV instead.", + "too_large": "This playlist is too large for a share link. Please export it as a CSV instead.", + "failed": "Could not create the link. Please try again.", + "invalid": "This playlist link is invalid, incomplete or unsupported. Ask the sender to copy the full link again.", + "home": "Go to Exportify", + "loading": "Loading shared playlist…", + "tagline": "A shared playlist snapshot.", + "snapshot_one": "{{count}} track · Shared snapshot", + "snapshot_other": "{{count}} tracks · Shared snapshot", + "snapshot_description": "This is a snapshot, not a live playlist. Later changes in Spotify will not appear here. No Spotify login is needed to view or download it.", + "download": "Download CSV", + "empty": "This playlist snapshot has no tracks." } } diff --git a/src/icons.ts b/src/icons.ts index d47ccc6..69752e0 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -1,7 +1,7 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { faSpotify } from '@fortawesome/free-brands-svg-icons' import { faCheckCircle, faTimesCircle, faFileArchive, faHeart } from '@fortawesome/free-regular-svg-icons' -import { faBolt, faServer, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck, faRecordVinyl } from '@fortawesome/free-solid-svg-icons' +import { faBolt, faServer, faMusic, faDownload, faCog, faSearch, faTimes, faSignOutAlt, faSync, faLightbulb, faCircleInfo, faGlobe, faCheck, faRecordVinyl, faLink } from '@fortawesome/free-solid-svg-icons' library.add( faSpotify, @@ -22,5 +22,6 @@ library.add( faCircleInfo, faGlobe, faCheck, - faRecordVinyl + faRecordVinyl, + faLink ) diff --git a/src/playlistShare.test.ts b/src/playlistShare.test.ts new file mode 100644 index 0000000..4371f5c --- /dev/null +++ b/src/playlistShare.test.ts @@ -0,0 +1,87 @@ +import { createHash } from "node:crypto" +import JSZip from "jszip" +import { server } from "./mocks/server" +import { handlerCalled, pagedGet } from "./mocks/handlers" +import { API, buildPlaylist, buildPlaylistItem, buildTrack } from "./test/fixtures/spotify/builders" +import { createPlaylistShareLink, decodePlaylistSnapshot, encodePlaylistSnapshot, MAX_SHARE_LINK_LENGTH, PlaylistSnapshot, sharedTrackUrl, ShareLinkTooLargeError } from "./playlistShare" + +const snapshot: PlaylistSnapshot = { + version: 1, + name: 'Музыка 🎶 / 日本語', + tracks: [ + ['spotify:track:1GrLfs4TEvAZ86HVzXHchS', 'Crying "again"\nNow', 'Six by Seven', 'Best of Six By Seven'], + ['spotify:track:1GrLfs4TEvAZ86HVzXHchS', 'Crying "again"\nNow', 'Six by Seven', 'Best of Six By Seven'], + ['spotify:local:artist:album:song:120', 'Local song', 'Local artist', 'Local album'], + ], +} + +const hashOf = (link: string) => new URL(link).hash +const archive = async (data: unknown) => '#playlist=' + await new JSZip().file('playlist.json', JSON.stringify(data)).generateAsync({ type: 'base64', compression: 'DEFLATE' }).then(value => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')) + +beforeEach(() => { + window.history.replaceState(null, '', '/exportify/?code=SECRET_CODE&app_client_id=PRIVATE_CLIENT#access_token=SECRET_TOKEN') + localStorage.setItem('access_token', 'TEST_ACCESS_TOKEN') +}) + +test('round-trips Unicode, duplicates and local tracks without copying callback or account data into the link', async () => { + const link = await encodePlaylistSnapshot(snapshot) + + expect(link).toMatch(/^https:\/\/exportify.app\/exportify\/#playlist=[\w-]+$/) + expect(link).not.toContain('SECRET') + expect(link).not.toContain('PRIVATE_CLIENT') + expect(await decodePlaylistSnapshot(hashOf(link))).toEqual(snapshot) + expect(await decodePlaylistSnapshot(hashOf(await encodePlaylistSnapshot({ version: 1, name: '', tracks: [] })))).toEqual({ version: 1, name: '', tracks: [] }) +}) + +test.each([1000, 2000])('shares all %i tracks through the paginated Spotify loader and reopens the complete snapshot', async count => { + const tracks = Array.from({ length: count }, (_, i) => buildTrack({ + id: createHash('sha256').update(`track-${i}`).digest('hex').slice(0, 22), + name: `Песня ${i} — ${createHash('sha256').update(`title-${i}`).digest('hex').slice(0, 18)}`, + album: { ...buildTrack().album, name: `Album ${i % 250}` }, + })) + const playlist = buildPlaylist({ id: 'large', name: 'Large playlist', tracks: { total: count } }) + server.use(pagedGet(`${API}/playlists/large/tracks`, tracks.map(track => buildPlaylistItem({ track })), 100)) + + const link = await createPlaylistShareLink(playlist) + const decoded = await decodePlaylistSnapshot(hashOf(link)) + + expect(link.length).toBeLessThan(MAX_SHARE_LINK_LENGTH) + expect(decoded.tracks).toEqual(tracks.map(track => [track.uri, track.name, track.artists.map(artist => artist.name).join(', '), track.album.name])) + expect(handlerCalled.mock.calls.map(([url]) => url)).toEqual(Array.from({ length: count / 100 }, (_, i) => `${API}/playlists/large/tracks?offset=${i * 100}&limit=100`)) +}) + +test('does not include added-by identities and skips unavailable tracks', async () => { + const playlist = buildPlaylist({ id: 'partial', tracks: { total: 2 } }) + server.use(pagedGet(`${API}/playlists/partial/tracks`, [buildPlaylistItem({ track: null }), buildPlaylistItem()], 100)) + const decoded = await decodePlaylistSnapshot(hashOf(await createPlaylistShareLink(playlist))) + + expect(decoded.tracks).toHaveLength(1) + expect(Object.keys(decoded)).toEqual(['version', 'name', 'tracks']) + expect(decoded.tracks[0]).toHaveLength(4) +}) + +test.each(['#playlist=', '#playlist=broken', '#playlist=%', '#other=data', '#playlist=' + 'x'.repeat(MAX_SHARE_LINK_LENGTH)])('rejects malformed or overlong links (%#)', async hash => { + await expect(decodePlaylistSnapshot(hash)).rejects.toThrow() +}) + +test.each([ + null, + { ...snapshot, version: 2 }, + { ...snapshot, name: 42 }, + { ...snapshot, tracks: [[1, 'name', 'artist', 'album']] }, + { ...snapshot, tracks: [['uri', 'name']] }, + { ...snapshot, tracks: Array(10_001).fill(snapshot.tracks[0]) }, +])('rejects unsupported versions and malformed snapshots (%#)', async data => { + await expect(decodePlaylistSnapshot(await archive(data))).rejects.toThrow('Invalid playlist snapshot') +}) + +test('rejects a small compressed archive that expands beyond the decoded limit', async () => { + await expect(decodePlaylistSnapshot(await archive({ ...snapshot, name: 'x'.repeat(2_000_001) }))).rejects.toThrow('too large') + await expect(encodePlaylistSnapshot({ ...snapshot, name: 'x'.repeat(2_000_001) })).rejects.toBeInstanceOf(ShareLinkTooLargeError) +}) + +test('links only to Spotify tracks and episodes, leaving local or untrusted URIs as plain text', () => { + expect(sharedTrackUrl(snapshot.tracks[0][0])).toBe('https://open.spotify.com/track/1GrLfs4TEvAZ86HVzXHchS') + expect(sharedTrackUrl('spotify:episode:1GrLfs4TEvAZ86HVzXHchS')).toBe('https://open.spotify.com/episode/1GrLfs4TEvAZ86HVzXHchS') + for (const uri of ['javascript:alert(1)', 'https://evil.example/', 'spotify:track:../../evil', snapshot.tracks[2][0]]) expect(sharedTrackUrl(uri)).toBeUndefined() +}) diff --git a/src/playlistShare.ts b/src/playlistShare.ts new file mode 100644 index 0000000..141fc9c --- /dev/null +++ b/src/playlistShare.ts @@ -0,0 +1,92 @@ +import JSZip from "jszip" +import TracksBaseData from "components/data/TracksBaseData" +import { TracksPlaylist } from "components/data/PlaylistsData" + +// Keep links and decoded snapshots bounded, including when reading untrusted links. +export const MAX_SHARE_LINK_LENGTH = 512_000 +const MAX_SNAPSHOT_LENGTH = 2_000_000 +const SHARE_PREFIX = "#playlist=" +const SNAPSHOT_FILE = "playlist.json" + +// Only listening metadata is shared; account IDs, tokens and added-by data stay out. +export type SharedTrack = [uri: string, name: string, artists: string, album: string] +export interface PlaylistSnapshot { + version: 1 + name: string + tracks: SharedTrack[] +} + +export class ShareLinkTooLargeError extends Error {} + +export const isPlaylistShare = (hash: string) => hash.startsWith(SHARE_PREFIX) + +export async function createPlaylistShareLink(playlist: TracksPlaylist): Promise { + const items = await new TracksBaseData(playlist).trackItems() + return encodePlaylistSnapshot({ + version: 1, + name: playlist.name, + tracks: items.map(({ track }) => [ + track.uri, track.name, track.artists.map(artist => artist.name ?? "").join(", "), track.album.name, + ]), + }) +} + +export async function encodePlaylistSnapshot(snapshot: PlaylistSnapshot): Promise { + const json = JSON.stringify(snapshot) + if (json.length > MAX_SNAPSHOT_LENGTH || snapshot.tracks.length > 10_000) throw new ShareLinkTooLargeError() + + const zip = new JSZip().file(SNAPSHOT_FILE, json) + const encoded = (await zip.generateAsync({ type: "base64", compression: "DEFLATE", compressionOptions: { level: 9 } })) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + // Never carry OAuth callback parameters, custom client IDs or other fragments into a share. + const link = `${window.location.origin}${window.location.pathname}${SHARE_PREFIX}${encoded}` + if (link.length > MAX_SHARE_LINK_LENGTH) throw new ShareLinkTooLargeError() + return link +} + +function readSnapshot(file: JSZip.JSZipObject): Promise { + // Stream the expansion so even a small malicious zip cannot allocate an unbounded JSON string. + return new Promise((resolve, reject) => { + let length = 0 + const chunks: string[] = [] + // JSZip exposes this browser streaming API but omits it from JSZipObject's types. + const stream = (file as JSZip.JSZipObject & { + internalStream(type: "string"): JSZip.JSZipStreamHelper + }).internalStream("string") + stream.on("data", chunk => { + length += chunk.length + if (length > MAX_SNAPSHOT_LENGTH) { + stream.pause() + reject(new Error("Playlist snapshot is too large")) + } else { + chunks.push(chunk) + } + }).on("error", reject).on("end", () => resolve(chunks.join(""))).resume() + }) +} + +export async function decodePlaylistSnapshot(hash: string): Promise { + const encoded = hash.slice(SHARE_PREFIX.length) + if (!isPlaylistShare(hash) || hash.length > MAX_SHARE_LINK_LENGTH || !/^[\w-]+$/.test(encoded)) { + throw new Error("Invalid playlist link") + } + const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - encoded.length % 4) % 4) + const zip = await JSZip.loadAsync(base64, { base64: true }) + const file = zip.file(SNAPSHOT_FILE) + if (!file || Object.keys(zip.files).length !== 1) throw new Error("Invalid playlist archive") + + const data: unknown = JSON.parse(await readSnapshot(file)) + if (typeof data !== "object" || data === null || !("version" in data) || data.version !== 1 || + !("name" in data) || typeof data.name !== "string" || !("tracks" in data) || !Array.isArray(data.tracks) || + data.tracks.length > 10_000 || !data.tracks.every((track: unknown) => + Array.isArray(track) && track.length === 4 && track.every(value => typeof value === "string"))) { + throw new Error("Invalid playlist snapshot") + } + // Return only the fields in the public format, even if a hand-crafted link contains extras. + return { version: 1, name: data.name, tracks: data.tracks.map(([uri, name, artists, album]) => [uri, name, artists, album]) } +} + +export function sharedTrackUrl(uri: string): string | undefined { + const match = /^spotify:(track|episode):([a-zA-Z0-9]{22})$/.exec(uri) + return match ? `https://open.spotify.com/${match[1]}/${match[2]}` : undefined +} From 7edc9367c098dd0f1b7a4b245eb34bada07a8bf8 Mon Sep 17 00:00:00 2001 From: dev-x64 <18104302+dev-x64@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:39:30 +0200 Subject: [PATCH 2/2] Compact playlist snapshots before DEFLATE compression --- README.md | 2 + src/components/PlaylistShare.test.tsx | 13 +++- src/playlistShare.test.ts | 64 +++++++++++++++++- src/playlistShare.ts | 93 +++++++++++++++++++++++++-- 4 files changed, 163 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 73896a3..b41dfbf 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ A snapshot includes the playlist name and each track's Spotify URI, name, artist 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 diff --git a/src/components/PlaylistShare.test.tsx b/src/components/PlaylistShare.test.tsx index ff8f232..496898f 100644 --- a/src/components/PlaylistShare.test.tsx +++ b/src/components/PlaylistShare.test.tsx @@ -2,10 +2,11 @@ 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, encodePlaylistSnapshot, PlaylistSnapshot } from "../playlistShare" +import { decodePlaylistSnapshot, PlaylistSnapshot } from "../playlistShare" import { ghostpoetTracksUrl, handlerCalled } from "../mocks/handlers" import { server } from "../mocks/server" import i18n from "../i18n/config" @@ -68,10 +69,16 @@ const snapshot: PlaylistSnapshot = { tracks: [['spotify:track:1GrLfs4TEvAZ86HVzXHchS', 'Crying "again"\nNow', 'Six by Seven', 'Best of Six By Seven']], } -test.each([false, true])('opens and downloads a snapshot without Spotify requests (logged in: %s)', async loggedIn => { +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 link = await encodePlaylistSnapshot(snapshot) + 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() diff --git a/src/playlistShare.test.ts b/src/playlistShare.test.ts index 4371f5c..36fcaf0 100644 --- a/src/playlistShare.test.ts +++ b/src/playlistShare.test.ts @@ -33,6 +33,18 @@ test('round-trips Unicode, duplicates and local tracks without copying callback expect(await decodePlaylistSnapshot(hashOf(await encodePlaylistSnapshot({ version: 1, name: '', tracks: [] })))).toEqual({ version: 1, name: '', tracks: [] }) }) +test('continues to open links in the original version 1 format', async () => { + expect(await decodePlaylistSnapshot(await archive(snapshot))).toEqual(snapshot) +}) + +test('does not lengthen links when the original format compresses better', async () => { + for (const source of [snapshot, { version: 1 as const, name: '', tracks: [] }]) { + const link = await encodePlaylistSnapshot(source) + expect(hashOf(link).length).toBeLessThanOrEqual((await archive(source)).length) + expect(await decodePlaylistSnapshot(hashOf(link))).toEqual(source) + } +}) + test.each([1000, 2000])('shares all %i tracks through the paginated Spotify loader and reopens the complete snapshot', async count => { const tracks = Array.from({ length: count }, (_, i) => buildTrack({ id: createHash('sha256').update(`track-${i}`).digest('hex').slice(0, 22), @@ -44,12 +56,62 @@ test.each([1000, 2000])('shares all %i tracks through the paginated Spotify load const link = await createPlaylistShareLink(playlist) const decoded = await decodePlaylistSnapshot(hashOf(link)) + const expected: PlaylistSnapshot = { + version: 1, name: playlist.name, + tracks: tracks.map(track => [track.uri, track.name, track.artists.map(artist => artist.name).join(', '), track.album.name]), + } expect(link.length).toBeLessThan(MAX_SHARE_LINK_LENGTH) - expect(decoded.tracks).toEqual(tracks.map(track => [track.uri, track.name, track.artists.map(artist => artist.name).join(', '), track.album.name])) + expect(hashOf(link).length).toBeLessThan((await archive(expected)).length) + expect(decoded).toEqual(expected) expect(handlerCalled.mock.calls.map(([url]) => url)).toEqual(Array.from({ length: count / 100 }, (_, i) => `${API}/playlists/large/tracks?offset=${i * 100}&limit=100`)) }) +const trackId = '1GrLfs4TEvAZ86HVzXHchS' +const compactSnapshot = [2, 'Compact 🎶', ['Artist', 'Album'], ['t' + trackId], ['Song'], [0], [1]] + +test('restores compact columns, repeated dictionary entries and duplicate, episode, local and arbitrary URIs exactly', async () => { + const uris = ['t' + trackId, 't' + trackId, 'e' + trackId, 'uspotify:local:artist:album:song:120', 'ut' + trackId, 'u', 'ujavascript:alert(1)'] + const data = [2, 'Compact 🎶', ['Artist', 'Album'], uris, Array(uris.length).fill('Song'), Array(uris.length).fill(0), Array(uris.length).fill(1)] + const decoded = await decodePlaylistSnapshot(await archive(data)) + + expect(decoded).toEqual({ + version: 1, name: 'Compact 🎶', + tracks: ['spotify:track:' + trackId, 'spotify:track:' + trackId, 'spotify:episode:' + trackId, + 'spotify:local:artist:album:song:120', 't' + trackId, '', 'javascript:alert(1)'] + .map(uri => [uri, 'Song', 'Artist', 'Album']), + }) + expect(await decodePlaylistSnapshot(await archive([2, '', [], [], [], [], []]))).toEqual({ version: 1, name: '', tracks: [] }) +}) + +test.each([-1, 0.5, 2, '0', null])('rejects invalid compact dictionary references (%#)', async index => { + for (const [artists, albums] of [[[index], [1]], [[0], [index]]]) { + await expect(decodePlaylistSnapshot(await archive([...compactSnapshot.slice(0, 5), artists, albums]))).rejects.toThrow('Invalid playlist snapshot') + } +}) + +test.each([ + [3, ...compactSnapshot.slice(1)], + compactSnapshot.slice(0, 6), + [...compactSnapshot, []], + [2, 42, ...compactSnapshot.slice(2)], + [2, 'name', [123], ...compactSnapshot.slice(3)], + [2, 'name', ['Artist', 'Album'], ['t' + trackId], [], [0], [1]], + [2, 'name', ['Artist', 'Album'], ['not-an-encoded-uri'], ['Song'], [0], [1]], + [2, 'name', ['Artist', 'Album'], ['tshort'], ['Song'], [0], [1]], + [2, 'name', ['Artist', 'Album'], ['t' + trackId], ['Song'], [], [1]], +])('rejects malformed compact columns and URI markers (%#)', async data => { + await expect(decodePlaylistSnapshot(await archive(data))).rejects.toThrow('Invalid playlist snapshot') +}) + +test('bounds the restored size of compact dictionary references before rendering', async () => { + const count = 10_000 + const data = [2, 'Small dictionary, large expansion', ['x'.repeat(200)], + Array(count).fill('t' + trackId), Array(count).fill('Song'), Array(count).fill(0), Array(count).fill(0)] + await expect(decodePlaylistSnapshot(await archive(data))).rejects.toThrow('too large') + await expect(decodePlaylistSnapshot(await archive([2, 'x'.repeat(1_999_970), [], [], [], [], []]))).rejects.toThrow('too large') +}) + test('does not include added-by identities and skips unavailable tracks', async () => { const playlist = buildPlaylist({ id: 'partial', tracks: { total: 2 } }) server.use(pagedGet(`${API}/playlists/partial/tracks`, [buildPlaylistItem({ track: null }), buildPlaylistItem()], 100)) diff --git a/src/playlistShare.ts b/src/playlistShare.ts index 141fc9c..1adf0af 100644 --- a/src/playlistShare.ts +++ b/src/playlistShare.ts @@ -5,6 +5,7 @@ import { TracksPlaylist } from "components/data/PlaylistsData" // Keep links and decoded snapshots bounded, including when reading untrusted links. export const MAX_SHARE_LINK_LENGTH = 512_000 const MAX_SNAPSHOT_LENGTH = 2_000_000 +const MAX_SHARE_TRACKS = 10_000 const SHARE_PREFIX = "#playlist=" const SNAPSHOT_FILE = "playlist.json" @@ -16,6 +17,13 @@ export interface PlaylistSnapshot { tracks: SharedTrack[] } +// Version 2 is a wire format only; the viewer still receives PlaylistSnapshot. +// Columns keep similar values together for DEFLATE's sliding compression window. +type CompactSnapshot = [ + version: 2, name: string, dictionary: string[], + uris: string[], names: string[], artists: number[], albums: number[], +] + export class ShareLinkTooLargeError extends Error {} export const isPlaylistShare = (hash: string) => hash.startsWith(SHARE_PREFIX) @@ -31,19 +39,91 @@ export async function createPlaylistShareLink(playlist: TracksPlaylist): Promise }) } -export async function encodePlaylistSnapshot(snapshot: PlaylistSnapshot): Promise { - const json = JSON.stringify(snapshot) - if (json.length > MAX_SNAPSHOT_LENGTH || snapshot.tracks.length > 10_000) throw new ShareLinkTooLargeError() +function packSnapshot(snapshot: PlaylistSnapshot): CompactSnapshot { + const dictionary: string[] = [] + const indices = new Map() + const intern = (value: string) => { + let index = indices.get(value) + if (index === undefined) { + index = dictionary.length + indices.set(value, index) + dictionary.push(value) + } + return index + } + const uris: string[] = [], names: string[] = [], artists: number[] = [], albums: number[] = [] + for (const [uri, name, artist, album] of snapshot.tracks) { + const match = /^spotify:(track|episode):([a-zA-Z0-9]{22})$/.exec(uri) + // The explicit raw marker also preserves local and arbitrary URIs without collisions. + uris.push(match ? (match[1] === "track" ? "t" : "e") + match[2] : "u" + uri) + names.push(name) + artists.push(intern(artist)) + albums.push(intern(album)) + } + return [2, snapshot.name, dictionary, uris, names, artists, albums] +} + +async function compressSnapshot(json: string): Promise { const zip = new JSZip().file(SNAPSHOT_FILE, json) - const encoded = (await zip.generateAsync({ type: "base64", compression: "DEFLATE", compressionOptions: { level: 9 } })) + return (await zip.generateAsync({ type: "base64", compression: "DEFLATE", compressionOptions: { level: 9 } })) .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +export async function encodePlaylistSnapshot(snapshot: PlaylistSnapshot): Promise { + const json = JSON.stringify(snapshot) + if (json.length > MAX_SNAPSHOT_LENGTH || snapshot.tracks.length > MAX_SHARE_TRACKS) throw new ShareLinkTooLargeError() + + const compactJson = JSON.stringify(packSnapshot(snapshot)) + // Tiny playlists or unique metadata can compress better in the original format. + // Compare actual compressed sizes so this optimization never lengthens a link. + const candidates = [json] + if (compactJson.length <= MAX_SNAPSHOT_LENGTH) candidates.push(compactJson) + const encoded = (await Promise.all(candidates.map(compressSnapshot))) + .reduce((shortest, candidate) => candidate.length < shortest.length ? candidate : shortest) // Never carry OAuth callback parameters, custom client IDs or other fragments into a share. const link = `${window.location.origin}${window.location.pathname}${SHARE_PREFIX}${encoded}` if (link.length > MAX_SHARE_LINK_LENGTH) throw new ShareLinkTooLargeError() return link } +const stringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every(item => typeof item === "string") + +const dictionaryIndex = (value: unknown, length: number): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 0 && value < length + +function unpackSnapshot(data: unknown[]): PlaylistSnapshot { + const [version, name, dictionary, uris, names, artists, albums] = data + if (data.length !== 7 || version !== 2 || typeof name !== "string" || + !stringArray(dictionary) || dictionary.length > MAX_SHARE_TRACKS * 2 || + !stringArray(uris) || !stringArray(names) || !Array.isArray(artists) || !Array.isArray(albums) || + uris.length > MAX_SHARE_TRACKS || [names, artists, albums].some(column => column.length !== uris.length)) { + throw new Error("Invalid playlist snapshot") + } + + const snapshot: PlaylistSnapshot = { version: 1, name, tracks: [] } + let expandedLength = JSON.stringify(snapshot).length + if (expandedLength > MAX_SNAPSHOT_LENGTH) throw new Error("Playlist snapshot is too large") + for (let i = 0; i < uris.length; i++) { + const artist: unknown = artists[i], album: unknown = albums[i] + const uri = uris[i] + const match = /^([te])([a-zA-Z0-9]{22})$/.exec(uri) + if ((!uri.startsWith("u") && !match) || + !dictionaryIndex(artist, dictionary.length) || !dictionaryIndex(album, dictionary.length)) { + throw new Error("Invalid playlist snapshot") + } + const expandedUri = uri.startsWith("u") ? uri.slice(1) : `spotify:${match![1] === "t" ? "track" : "episode"}:${match![2]}` + const track: SharedTrack = [expandedUri, names[i], dictionary[artist], dictionary[album]] + // Dictionary references can expand into much more text than the compressed JSON. + // Apply the same limit to the restored snapshot before the viewer receives it. + expandedLength += JSON.stringify(track).length + (i > 0 ? 1 : 0) + if (expandedLength > MAX_SNAPSHOT_LENGTH) throw new Error("Playlist snapshot is too large") + snapshot.tracks.push(track) + } + return snapshot +} + function readSnapshot(file: JSZip.JSZipObject): Promise { // Stream the expansion so even a small malicious zip cannot allocate an unbounded JSON string. return new Promise((resolve, reject) => { @@ -76,9 +156,12 @@ export async function decodePlaylistSnapshot(hash: string): Promise 10_000 || !data.tracks.every((track: unknown) => + data.tracks.length > MAX_SHARE_TRACKS || !data.tracks.every((track: unknown) => Array.isArray(track) && track.length === 4 && track.every(value => typeof value === "string"))) { throw new Error("Invalid playlist snapshot") }