Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ The build is Vite (`vite.config.ts`, with `index.html` at the root) and the test
- Tests are React Testing Library plus MSW (`src/mocks/handlers.ts`). Prefer behaviour-level tests over implementation details.
- Spotify fixtures come from real responses recorded from the test account, scrubbed of tokens and account identifiers. Never commit raw captures, HAR files (they carry bearer tokens) or anything from a personal account.
- There's no Playwright or screenshot suite on purpose. UI-affecting changes (dependency upgrades, styling) get a before/after agent-browser screenshot check against `mise s` with the test account instead.

## Liked-songs playlist sorter (`sorter/`)

Separate from the app: Python scripts that sort the maintainer's exported Liked Songs into their 25 playlists. When the maintainer hands over a Liked Songs CSV and asks to sort new songs, follow `sorter/README.md`. Their playlist data is gitignored (this repo is public). Restore it from the published "Liked Songs, Sorted" page with `tools/from_page.py`, and republish to the same link afterwards. Never commit CSVs or `sorter/data/playlists.json`.
9 changes: 9 additions & 0 deletions sorter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Personal listening data stays out of this public repo. The published "Liked Songs, Sorted" page holds it;
# tools/from_page.py restores these files from that page.
data/playlists.json
data/state.json
site/index.html
*.csv
covers/
fonts/
__pycache__/
46 changes: 46 additions & 0 deletions sorter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Liked-songs playlist sorter

Sorts Spotify Liked Songs (exported with this Exportify fork) into 25 playlists by each song's primary artist,
and builds a page with one card per playlist. Each card has buttons that copy Spotify track links, which you paste
into a playlist in the Spotify desktop app (Ctrl+V / ⌘V). Nothing here writes to Spotify: the app stays read-only.

Python 3 standard library only (covers need `pillow numpy opencv-python`).

## Where the data lives

The repo is public, so the playlist contents (`data/playlists.json`, `data/state.json`) and CSV exports are
gitignored. The published page **Liked Songs, Sorted** (https://claude.ai/artifact/M7jjV7YcWMxLdzbwYyqhm1)
holds both files, and `tools/from_page.py` restores them from it.

## Adding newly liked songs (the usual update)

1. On exportify.app (browser in English), export **Liked Songs** to CSV. Turning on the artist genres
option helps sort unknown artists.
2. Give the CSV to Claude Code and ask it to sort the new liked songs. Claude then:
1. saves the published page and runs `python tools/from_page.py <page.html>`;
2. runs `python tools/update.py liked.csv` (dry run) and shows where each new song goes;
3. sorts any `??` artists by adding them to `tools/genres2.py` (or uses `--place ID="playlist"` / `--skip ID`);
4. runs `python tools/update.py liked.csv --apply`, which rebuilds `site/index.html`;
5. republishes the page to the same link.
3. On the page, each card that got songs has a **Copy N new songs** button. Paste those into the matching
Spotify playlist.

`update.py` only looks at songs liked since the last update (`state.json`'s `last_update`), skips songs already
in a playlist (including other versions of the same song), and flags the new ones `fresh`. Each run clears the
previous run's `fresh` flags; pass `--keep-fresh` if those weren't pasted yet. Where a song goes:

1. a `--place` override;
2. a hand-curated playlist (french vibes, café parisien, lofi study) when most of the artist's songs are there;
3. the genre rule from `tools/classify.py` (pop and rap split by mood and year), if the artist already has songs in that playlist;
4. else the playlist that already holds most of the artist's songs;
5. else the genre rule, or `??` for an artist no rule knows.

## Files

- `tools/classify.py`: genre rules, artist → genre → playlist. Also works on its own: `python tools/classify.py new.csv old.csv`.
- `tools/genres.py`, `tools/genres2.py`: artist → genre maps; add new artists to `genres2.py`.
- `tools/update.py`: sorts new liked songs into `data/playlists.json`.
- `tools/build_page.py`: `data/` + `site/template.html` → `site/index.html`.
- `tools/from_page.py`: built page → `data/playlists.json` + `data/state.json`.
- `tools/covers.py`, `tools/get_fonts.sh`: playlist covers (`bash tools/get_fonts.sh`, then `python tools/covers.py ["name" ...]`; each must stay under Spotify's 256 KB limit).
- `data/spotify_descriptions.json`: paste-ready playlist descriptions.
27 changes: 27 additions & 0 deletions sorter/data/spotify_descriptions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"arabesque": "Arabic pop to timeless tarab. From Amr Diab, Sherine and Nancy Ajram to Saint Levant and DYSTINCT, winding down with Majida El Roumi, Fairuz and Warda. Genre: Arabic pop, Khaleeji, tarab, nasheed.",
"french vibes": "Parisian nights, afro-French bounce. Aya Nakamura, Tiakola, Dadju, Tayc, Stromae and Joé Dwèt Filé. Genre: French pop, afro-pop, French rap.",
"café parisien": "A slow coffee on a Paris terrace. Charles Trenet, Édith Piaf, Joe Dassin, Zaz, Indila and Christine and the Queens. Genre: chanson, French classics, soft pop.",
"in my feels": "Rap for the overthinking hours. Juice WRLD, NF, Mac Miller, Drake, Post Malone and Polo G. Genre: melodic rap, emo rap, conscious hip-hop.",
"pre-workout": "No warm-up needed. Travis Scott, Future, Lil Uzi Vert, Playboi Carti, Metro Boomin and Kendrick Lamar. Genre: trap, hype rap, hip-hop.",
"rap throwbacks": "The rap you grew up on. Eminem, Lil Wayne, Kanye, Wiz Khalifa, Snoop Dogg and Macklemore. Genre: 2000s and early 2010s hip-hop.",
"pop on repeat": "The pop you can't skip. Dua Lipa, Charlie Puth, Justin Bieber, Rihanna, Doja Cat and Sia. Genre: modern pop, dance-pop.",
"throwback pop": "Aux cord, 2012. Katy Perry, One Direction, Pitbull, Jason Derulo, Selena Gomez and Taio Cruz. Genre: 2000s and 2010s pop.",
"3am thoughts": "For when you can't sleep. Ali Gatie, Lauv, Lewis Capaldi, Billie Eilish, Teddy Swims and James Arthur. Genre: sad pop, ballads, acoustic.",
"festival mode": "Main stage energy. Avicii, Marshmello, The Chainsmokers, Kygo, David Guetta and Calvin Harris. Genre: EDM, house, dance.",
"stadium anthems": "Arms up, sing it back. Coldplay, Imagine Dragons, OneRepublic, AJR, Twenty One Pilots and Bastille. Genre: pop-rock, alt-pop.",
"indie sunsets": "Golden hour with the windows down. Dominic Fike, Rex Orange County, Tame Impala, beabadoobee, TV Girl and Steve Lacy. Genre: indie, bedroom pop, alt.",
"lofi study": "Soft instrumental beats, no lyrics, just focus. Nujabes, Jinsang, Idealism, Kupla, Tomppabeats and WYS. Genre: lofi hip-hop, chillhop, instrumental.",
"Underwater": "Everything slowed down and drifting. slō, twilight, Shiloh Dynasty, untrusted and Powfu. Genre: slowed + reverb, sad lofi, bedroom pop.",
"bollywood nights": "Filmi hits and Punjabi bangers. Pritam, Arijit Singh, Vishal-Shekhar, Shreya Ghoshal and AP Dhillon. Genre: Bollywood, Punjabi, desi pop.",
"reggaetón": "Perreo all night. Bad Bunny, J Balvin, Ozuna, Daddy Yankee, Shakira and Rauw Alejandro. Genre: reggaeton, Latin trap, Latin pop.",
"afrobeats": "Lagos to London. Burna Boy, Wizkid, Rema, CKay, Tems, OMAH LAY and Asake. Genre: Afrobeats, afropop, amapiano.",
"afro house": "Warm drums and deep grooves. Ginton, Trinix, Craig Isto, June Freedom, Naïka and Black Coffee. Genre: afro house, afro-fusion, deep house.",
"uk rap": "Straight from the ends. Dave, Central Cee, AJ Tracey, T MULLA, J Hus and Stormzy. Genre: UK rap, drill, grime.",
"late night r&b": "Lights low, volume up. Khalid, The Weeknd, Brent Faiyaz, SZA, Daniel Caesar and GIVĒON. Genre: R&B, neo-soul, alt-R&B.",
"golden oldies": "Songs that never get old. ABBA, Michael Jackson, Queen, The Beach Boys, Fleetwood Mac and Bonnie Tyler. Genre: classic rock, disco, soul, oldies.",
"back roads": "Dirt roads and open skies. Noah Kahan, Zach Bryan, Lord Huron, The Lumineers, Vance Joy and Hozier. Genre: folk, country, Americana.",
"smoky jazz bar": "Dim lights in a 1940s jazz bar. Laufey, Frank Sinatra, Ella Fitzgerald, Etta James and Michael Bublé. Genre: jazz, swing, vintage, crooners.",
"island time": "Sun out, worries off. Bob Marley, Shaggy, UB40, Damian Marley, Sean Paul and Koffee. Genre: reggae, dancehall.",
"pop punk": "Loud, fast, forever 17. Green Day, Paramore, Fall Out Boy, Panic! At The Disco, blink-182 and All Time Low. Genre: pop-punk, emo, alt-rock."
}
80 changes: 80 additions & 0 deletions sorter/site/template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<title>Liked Songs, Sorted</title>
<meta name="claude:viewport-fit-sides" content="cover">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,750&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@500&display=swap">
<style>
/* Layout: a crate of record sleeves — intro strip, then a grid of playlist cards that open into track lists */
:root{--bg:#eef1f6;--card:#ffffff;--fg:#1b2133;--muted:#5d6680;--line:#d6dbe7;--accent:#e2572b;--accent-ink:#fff;--chip:#e4e8f1;
--display:"Bricolage Grotesque",ui-sans-serif,system-ui,sans-serif;--body:"Instrument Sans",ui-sans-serif,system-ui,sans-serif;--mono:"JetBrains Mono",ui-monospace,monospace}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#121624;--card:#1b2032;--fg:#e8ebf4;--muted:#99a1b9;--line:#2c3350;--accent:#ff7a4d;--accent-ink:#1a0e08;--chip:#262c44;color-scheme:dark}}
:root[data-theme="dark"]{--bg:#121624;--card:#1b2032;--fg:#e8ebf4;--muted:#99a1b9;--line:#2c3350;--accent:#ff7a4d;--accent-ink:#1a0e08;--chip:#262c44;color-scheme:dark}
body{background:var(--bg);color:var(--fg);font:15px/1.5 var(--body)}
.wrap{max-width:1100px;margin:0 auto;padding-block:32px 64px;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));display:grid;gap:28px}
h1{font:750 clamp(30px,5vw,48px)/1.05 var(--display);margin:0;text-wrap:balance;letter-spacing:-.01em}
h2{font:750 13px var(--body);text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:0}
.lede{color:var(--muted);max-width:62ch;margin:8px 0 0}
.stats{display:flex;flex-wrap:wrap;gap:8px 24px;font:500 13px var(--mono);color:var(--muted)}
.stats b{color:var(--fg);font-weight:500}
.how{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px 20px;display:grid;gap:6px;max-width:72ch}
.how ol{margin:0;padding-left:20px;display:grid;gap:4px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:14px}
.pl{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;display:flex;flex-direction:column;gap:10px;min-width:0}
.top{display:flex;justify-content:space-between;align-items:baseline;gap:10px}
.name{font:750 22px/1.15 var(--display);margin:0}
.count{font:500 13px var(--mono);color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
.desc{color:var(--muted);font-size:14px;margin:0}
.chip{align-self:flex-start;font:600 11px var(--body);text-transform:uppercase;letter-spacing:.07em;padding:3px 8px;border-radius:99px;background:var(--chip);color:var(--muted)}
.chip.new{background:var(--accent);color:var(--accent-ink)}
.row{display:flex;gap:8px;flex-wrap:wrap;margin-top:auto}
button{font:600 14px var(--body);border-radius:7px;padding:8px 14px;cursor:pointer;border:1px solid var(--line);background:transparent;color:var(--fg)}
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
ul.tracks{list-style:none;margin:0;padding:0;max-height:300px;overflow:auto;border-top:1px solid var(--line)}
ul.tracks li{padding:6px 2px;border-bottom:1px solid var(--line);font-size:14px;display:flex;gap:8px;min-width:0}
ul.tracks li a{color:var(--fg);text-decoration:none;flex:1;min-width:0;overflow-wrap:anywhere}
ul.tracks li a:hover{color:var(--accent)}
ul.tracks .ar{color:var(--muted)}
.n{font:500 12px var(--mono);color:var(--muted);min-width:2.4em;text-align:right;padding-top:2px}
textarea{width:100%;box-sizing:border-box;height:90px;font:12px var(--mono);background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px}
.newtag{font:600 11px var(--body);text-transform:uppercase;letter-spacing:.06em;color:var(--accent);white-space:nowrap}
.recs{display:grid;gap:4px;font-size:14px}
.recs h4{margin:0;font:600 12px var(--body);text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}
.recs ul{margin:0;padding:0;list-style:none;display:flex;flex-wrap:wrap;gap:6px}
.recs a{display:inline-block;padding:3px 9px;border-radius:99px;background:var(--chip);color:var(--fg);text-decoration:none;font-size:13px}
.recs a:hover{color:var(--accent)}
.toast{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(20px + env(safe-area-inset-bottom,0px));background:var(--fg);color:var(--bg);padding:10px 16px;border-radius:8px;font-weight:600}
</style>
<div class="wrap">
<header><h1>Liked Songs, Sorted</h1>
<p class="lede">Built from your liked songs only. Your old playlists weren't used at all. Every song is placed by its main artist's sound, so genres that were mixed together before are now split apart. Duplicate versions of the same song are removed. Songs marked <span class="newtag">New for you</span> are picks you don't have yet, and each card also lists more suggestions to look up on Spotify.</p></header>
<div class="stats" id="stats"></div>
<section class="how"><h2>Adding a playlist in Spotify</h2>
<ol><li>In the Spotify <b>desktop app</b>, create a playlist with the name shown. These names don't match your old playlists, so you can keep or delete those separately.</li>
<li>Click <b>Copy song links</b> on that card below.</li>
<li>Click inside the empty playlist and paste (Ctrl+V / ⌘V). All the songs get added at once.</li></ol></section>
<h2 id="plcount">Your playlists</h2><div class="grid" id="new"></div>
</div>
<div class="toast" id="toast" hidden></div>
<script>
const DATA=__DATA__;
const STATE=__STATE__;
const toast=m=>{const t=document.getElementById('toast');t.textContent=m;t.hidden=false;clearTimeout(t._h);t._h=setTimeout(()=>t.hidden=true,2200)};
let tot=0;
DATA.forEach((p,i)=>{tot+=p.tracks.length;
const c=document.createElement('article');c.className='pl';
const nn=p.tracks.filter(t=>t.new).length;
c.innerHTML=`<div class="top"><h3 class="name"></h3><span class="count">${p.tracks.length} songs${nn?` · ${nn} new`:''}</span></div>
<p class="desc"></p><div class="row"><button class="primary" id="c${i}">Copy song links</button><button id="d${i}">Copy description</button>${p.tracks.some(t=>t.fresh)?`<button class="primary" id="f${i}">Copy ${p.tracks.filter(t=>t.fresh).length} new songs</button>`:''}<button id="s${i}" aria-expanded="false">Show songs</button></div>
<ul class="tracks" hidden></ul><div class="recs" hidden><h4>More to try on Spotify</h4><ul></ul></div><textarea hidden readonly></textarea>`;
c.querySelector('.name').textContent=p.name;
const fb=c.querySelector('#f'+i); if(fb) fb.onclick=()=>{const L=p.tracks.filter(t=>t.fresh).map(t=>t.u).join('\n');navigator.clipboard.writeText(L).then(()=>toast(`Copied new songs for “${p.name}”`)).catch(()=>{ta.value=L;ta.hidden=false;ta.select();toast('Press Ctrl+C / ⌘C to copy the selected links')})};
c.querySelector('#d'+i).onclick=()=>{navigator.clipboard.writeText(p.sd).then(()=>toast(`Copied description for “${p.name}”`)).catch(()=>{ta.value=p.sd;ta.hidden=false;ta.select();toast('Press Ctrl+C / ⌘C to copy the selected text')})};c.querySelector('.desc').textContent=p.sd;
const ul=c.querySelector('ul.tracks'),ta=c.querySelector('textarea');
const links=p.tracks.map(t=>t.u).join('\n');
c.querySelector('#s'+i).onclick=e=>{if(!ul.childElementCount){p.tracks.forEach((t,j)=>{const li=document.createElement('li');li.innerHTML=`<span class="n">${j+1}</span><a target="_blank" rel="noopener"></a>`;const a=li.querySelector('a');a.href=t.u;a.textContent=t.n+' ';const s=document.createElement('span');s.className='ar';s.textContent='· '+t.a;a.append(s);if(t.new){const g=document.createElement('span');g.className='newtag';g.textContent=' New for you';a.append(g)}if(t.fresh){const g=document.createElement('span');g.className='newtag';g.textContent=' Just liked';a.append(g)}ul.append(li)})}
ul.hidden=!ul.hidden;e.target.textContent=ul.hidden?'Show songs':'Hide songs';e.target.setAttribute('aria-expanded',!ul.hidden)};
c.querySelector('#c'+i).onclick=()=>{navigator.clipboard.writeText(links).then(()=>toast(`Copied ${p.tracks.length} links for “${p.name}”`)).catch(()=>{ta.value=links;ta.hidden=false;ta.select();toast('Press Ctrl+C / ⌘C to copy the selected links')})};
if(p.recs.length){const r=c.querySelector('.recs');r.hidden=false;p.recs.forEach(x=>{const li=document.createElement('li');const a=document.createElement('a');a.href=x.u;a.target='_blank';a.rel='noopener';a.textContent=x.n+' · '+x.a;li.append(a);r.querySelector('ul').append(li)})}
document.getElementById('new').append(c)});
document.getElementById('stats').innerHTML=`<span><b>${tot.toLocaleString()}</b> songs</span><span><b>${DATA.length}</b> playlists</span><span><b>${DATA.reduce((s,p)=>s+p.tracks.filter(t=>t.new).length,0)}</b> new picks added</span><span><b>${DATA.reduce((s,p)=>s+p.recs.length,0)}</b> more to try</span><span><b>${DATA.reduce((s,p)=>s+p.tracks.filter(t=>t.fresh).length,0)}</b> just liked</span>${STATE.last_update?`<span>updated <b>${STATE.last_update.slice(0,10)}</b></span>`:''}`;
document.getElementById('plcount').textContent=`Your ${DATA.length} playlists`;
</script>
24 changes: 24 additions & 0 deletions sorter/tools/build_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Inline data/playlists.json into site/template.html -> site/index.html (the copy-paste playlist page).
playlists.json track rows are [spotify_track_id, name, artists, *flags]; flags: 'new' = recommended song not in
liked songs, 'fresh' = newly liked since last update (gets a 'Copy N new songs' button).
data/state.json (last_update) is embedded too, so tools/from_page.py can restore both files from the page."""
import json, os, urllib.parse
R = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
data = json.load(open(os.path.join(R, 'data/playlists.json'), encoding='utf-8'))
page = []
for p in data:
tr = []
for row in p['tracks']:
t = {'n': row[1], 'a': row[2], 'u': 'https://open.spotify.com/track/' + row[0]}
for f in row[3:]: t[f] = 1
tr.append(t)
recs = [{'n': n, 'a': a, 'u': 'https://open.spotify.com/search/' + urllib.parse.quote(n + ' ' + a)} for n, a in p['recs']]
page.append({'name': p['name'], 'desc': p['desc'], 'sd': p['spotify_description'], 'tracks': tr, 'recs': recs})
state_path = os.path.join(R, 'data/state.json')
state = json.load(open(state_path, encoding='utf-8')) if os.path.exists(state_path) else {}
tpl = open(os.path.join(R, 'site/template.html'), encoding='utf-8').read()
# </ is escaped so a song title can never close the <script> tag
js = lambda v: json.dumps(v, ensure_ascii=False).replace('</', '<\\/')
open(os.path.join(R, 'site/index.html'), 'w', encoding='utf-8').write(
tpl.replace('__DATA__', js(page)).replace('__STATE__', js(state)))
print('wrote site/index.html with', len(page), 'playlists,', sum(len(p['tracks']) for p in page), 'songs')
Loading