Spotify Data
Import a Spotify "Extended streaming history" GDPR export into swamp — one
spotify.<year> resource per calendar year, partitioned exactly like
@magistr/lastfm's scrobble chunks so the two sources sit alongside each
other and stay comparable.
Model:
@magistr/spotify-data—inspectreports what an export contains (record counts, the music/podcast split, per-year distribution, date range) without writing anything;importwrites the year chunks plus animport.<label>state resource. Both are idempotent.
This is an OFFLINE importer: no network call, no credential. The export is
the ZIP Spotify emails you from https://www.spotify.com/account/privacy/ —
request "Extended streaming history", not the plain "Account data" package,
which holds only the last twelve months. The Web API cannot substitute for
it: recently-played returns only the last 50 tracks.
Privacy: every export record carries ip_addr, and most carry
conn_country, platform and incognito_mode. That is a location and
device trail rather than listening data, so all four are dropped at the
parse boundary and never reach a resource.
Fidelity: podcast episodes are excluded, but every music stream is kept regardless of how briefly it played — Last.fm only ever recorded plays past its ~30s threshold, so the sub-threshold tail is precisely what this source adds. Filtering by play time is a read-time concern.
2026.08.02.1
First release of @magistr/spotify-data — an offline importer for a Spotify
"Extended streaming history" GDPR export.
Model
@magistr/spotify-data with two methods, both idempotent and neither making a
network call or holding a credential:
inspect— reports record counts, the music/podcast/audiobook split, per-year distribution and date range without writing a year chunk. Writes oneinspection.<label>resource.import— writes onespotify.<year>resource per calendar year plus animport.<label>state resource. An optionalyearargument narrows the run to a single calendar year.
Chunks are partitioned exactly like @magistr/lastfm's scrobble chunks so the
two sources stay comparable. A year chunk carries no wall-clock field, so
re-importing an unchanged export rewrites identical bytes rather than minting a
new data version every run.
Privacy boundary
Every export record carries ip_addr, and most carry conn_country, platform
and incognito_mode — a location and device trail spanning years. All four are
dropped in toStream, the single point every record passes through, and never
reach a resource. incognito_mode is dropped rather than stored as false, so
a private play and an ordinary one are byte-identical in the stored row.
This is treated as the model's security property rather than a nicety: the
committed fixture carries all four fields on every record so their absence is
observable, and a property test asserts no generated secret value survives
toStream under any record shape.
Fidelity
Podcast episodes and audiobook chapters are excluded, but every music stream is
kept regardless of play time. Last.fm only recorded plays past its ~30s
threshold, so the sub-threshold tail is what this source uniquely adds;
filtering by play time is left to the reports, which report total (every
stream) and listens (past 30s) as two deliberately non-interchangeable
numbers.
Deduplication keys on the whole record
Identity is the entire normalized record, not (ts, artist, track). On a real
15,607-record export, 546 groups share that triple: 469 are byte-identical
export artifacts, correct to collapse, but 77 differ in ms_played and
sometimes reason_start/reason_end — plays Spotify emitted as several
segments stamped with one end second. The coarser key would have discarded them
as duplicates and silently lost real playback data. The key's delimiter is NUL,
since a printable separator makes ("a b", "c") and ("a", "b c") collide.
Reports
@magistr/spotify-stats— playcounts, hours, top artists/tracks by both playcount and time, skip rate, and year/hour/reason_enddistributions.@magistr/listening-overlap— cross-references against a@magistr/lastfminstance. Matching is one-to-one within a tolerance window (default 600s), because Spotify'stsis an END timestamp while Last.fm'sdate.utsis a START timestamp, and because a naive "any counterpart exists" test double-counts repeated plays. Artist ranking folds spelling variants, merging theZa Frûmi/Za Frűmisplit that costs that artist half their real playcount on Last.fm.
Tests
All five suites present, 89 tests:
- contract-fixture (11) — the parse boundary against the documented export record shape, including the privacy pins and the UTC year boundary
- methods (18) — both methods' success and failure paths against real temp-dir exports
- adversarial (14) — hostile export content, the privacy boundary under attack, and key/name collision
- coverage (18) — one test per guard, including the empty-
episode_namecase that would otherwise classify every music play as a podcast - property-invariant-flow (11 + 27 report tests) — no-loss, idempotence, order
independence, key soundness and privacy totality under
fast-check
Fixtures are hand-authored rather than captured: a real export is one of the
most personal files a person owns, and committing even a redacted slice would
put that trail one git log away. See fixtures/PROVENANCE.md.
Resources
Reconcile the Last.fm scrobble history against the Spotify streaming
Listening statistics from the imported Spotify streaming history: hours
- Has README or module doc2/2earned
- README has a code example1/1earned
- README is substantive1/1earned
- Most symbols documented1/1earned
- No slow types (deprecated)1/1earned
- Dependencies pass trust audit2/2earned
- Has description1/1earned
- Platform support declared (or universal)2/2earned
- License declared1/1earned
- Verified public repository2/2earned