From 9fa5b469701402974471dca334eed723bc5cd556 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 05:54:04 +0000 Subject: [PATCH] Add the liked-songs playlist sorter Python scripts that sort a Liked Songs export into the 25 playlists and build the copy-links page. update.py adds only songs liked since the last run, respects hand-curated placements, and flags them "fresh". The repo is public, so the playlist data and CSVs are gitignored. from_page.py restores them from the published page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011eRJtCuSok2Nyov6d4VBLP --- CLAUDE.md | 4 + sorter/.gitignore | 9 + sorter/README.md | 46 ++++ sorter/data/spotify_descriptions.json | 27 +++ sorter/site/template.html | 80 +++++++ sorter/tools/build_page.py | 24 ++ sorter/tools/classify.py | 64 ++++++ sorter/tools/covers.py | 307 ++++++++++++++++++++++++++ sorter/tools/from_page.py | 23 ++ sorter/tools/genres.py | 43 ++++ sorter/tools/genres2.py | 36 +++ sorter/tools/get_fonts.sh | 14 ++ sorter/tools/update.py | 129 +++++++++++ 13 files changed, 806 insertions(+) create mode 100644 sorter/.gitignore create mode 100644 sorter/README.md create mode 100644 sorter/data/spotify_descriptions.json create mode 100644 sorter/site/template.html create mode 100644 sorter/tools/build_page.py create mode 100644 sorter/tools/classify.py create mode 100644 sorter/tools/covers.py create mode 100644 sorter/tools/from_page.py create mode 100644 sorter/tools/genres.py create mode 100644 sorter/tools/genres2.py create mode 100644 sorter/tools/get_fonts.sh create mode 100644 sorter/tools/update.py diff --git a/CLAUDE.md b/CLAUDE.md index 538b667..ddbe1a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/sorter/.gitignore b/sorter/.gitignore new file mode 100644 index 0000000..d19c707 --- /dev/null +++ b/sorter/.gitignore @@ -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__/ diff --git a/sorter/README.md b/sorter/README.md new file mode 100644 index 0000000..e7d4c29 --- /dev/null +++ b/sorter/README.md @@ -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 `; + 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. diff --git a/sorter/data/spotify_descriptions.json b/sorter/data/spotify_descriptions.json new file mode 100644 index 0000000..685c02f --- /dev/null +++ b/sorter/data/spotify_descriptions.json @@ -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." +} diff --git a/sorter/site/template.html b/sorter/site/template.html new file mode 100644 index 0000000..238b9cc --- /dev/null +++ b/sorter/site/template.html @@ -0,0 +1,80 @@ +Liked Songs, Sorted + + + +
+

Liked Songs, Sorted

+

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 New for you are picks you don't have yet, and each card also lists more suggestions to look up on Spotify.

+
+

Adding a playlist in Spotify

+
  1. In the Spotify desktop app, create a playlist with the name shown. These names don't match your old playlists, so you can keep or delete those separately.
  2. +
  3. Click Copy song links on that card below.
  4. +
  5. Click inside the empty playlist and paste (Ctrl+V / ⌘V). All the songs get added at once.
+

Your playlists

+
+ + diff --git a/sorter/tools/build_page.py b/sorter/tools/build_page.py new file mode 100644 index 0000000..a169c5b --- /dev/null +++ b/sorter/tools/build_page.py @@ -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() +# tag +js = lambda v: json.dumps(v, ensure_ascii=False).replace(' playlist "Underwater". +# The real "lofi study" playlist (instrumental) and the French / Arabic-ballad picks are hand-curated. + +def artists(s): + """Split Exportify's artist column: names joined by ', ', commas inside a name escaped as '\\,'.""" + return [a.replace('\\,', ',') for a in re.split(r'(? 2: + old = {r['Track URI'] for r in csv.DictReader(open(sys.argv[2], encoding='utf-8'))} + rows = [r for r in rows if r['Track URI'] not in old] + for k, v in sorted(classify(rows).items(), key=lambda x: -len(x[1])): + print(f'== {KEY_TO_PLAYLIST.get(k, k)} ({len(v)})') + for r in v: print(f" {r['Track Name']} | {r['Artist Name(s)']} | https://open.spotify.com/track/{r['Track URI'].split(':')[-1]}") diff --git a/sorter/tools/covers.py b/sorter/tools/covers.py new file mode 100644 index 0000000..6b31fa9 --- /dev/null +++ b/sorter/tools/covers.py @@ -0,0 +1,307 @@ +import math, random, os, io, sys +import numpy as np, cv2 +from PIL import Image, ImageDraw, ImageFont, ImageFilter + +S = 1200 +D = os.path.dirname(os.path.abspath(__file__)) +FD = os.path.join(D, '..', 'fonts'); OUT = os.path.join(D, '..', 'covers'); os.makedirs(OUT, exist_ok=True) +FONTS = { + 'anton': 'Anton-Regular', 'bebas': 'BebasNeue-Regular', 'abril': 'AbrilFatface-Regular', 'bungee': 'Bungee-Regular', + 'lobster': 'Lobster-Regular', 'pacifico': 'Pacifico-Regular', 'shrikhand': 'Shrikhand-Regular', 'archivo': 'ArchivoBlack-Regular', + 'dmserif': 'DMSerifDisplay-Regular', 'dmital': 'DMSerifDisplay-Italic', 'instital': 'InstrumentSerif-Italic', + 'pfital': 'PlayfairDisplay-Italic', 'playfair': 'PlayfairDisplay', 'italiana': 'Italiana-Regular', 'rye': 'Rye-Regular', + 'righteous': 'Righteous-Regular', 'bowlby': 'BowlbyOneSC-Regular', 'unbounded': 'Unbounded', 'monoton': 'Monoton-Regular', + 'caveat': 'Caveat', 'yatra': 'YatraOne-Regular', 'vt323': 'VT323-Regular', 'rubikmono': 'RubikMonoOne-Regular', +} +_fc = {} +def F(n, s): + k = (n, s) + if k not in _fc: + f = ImageFont.truetype(f'{FD}/{FONTS[n]}.ttf', s) + if n in ('playfair', 'pfital', 'unbounded'): + try: f.set_variation_by_name('Black') + except Exception: + try: f.set_variation_by_name('Bold') + except Exception: pass + if n == 'caveat': + try: f.set_variation_by_name('Bold') + except Exception: pass + _fc[k] = f + return _fc[k] + +def hexc(h, a=255): + h = h.lstrip('#'); return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), a) + +def layer(w=S, h=S): return Image.new('RGBA', (w, h), (0, 0, 0, 0)) + +# ---------- backgrounds +def vgrad(cols, power=1.0): + cs = [np.array(hexc(c)[:3], float) for c in cols] + t = np.linspace(0, 1, S) ** power + seg = len(cs) - 1 + arr = np.zeros((S, 3)) + for i, v in enumerate(t): + k = min(int(v * seg), seg - 1); u = v * seg - k + arr[i] = cs[k] * (1 - u) + cs[k + 1] * u + arr = np.repeat(arr[:, None, :], S, axis=1) + return Image.fromarray(arr.astype(np.uint8)).convert('RGBA') + +def marble(cols, seed=0, scale=1.0, bands=2.2): + rng = np.random.default_rng(seed) + N = 300 + y, x = np.mgrid[0:N, 0:N].astype(np.float32) / N * scale + def n(px, py, oct=3): + v = np.zeros_like(px); amp = 1 + for o in range(oct): + a, b, c, d = rng.uniform(0.7, 1.8, 4) * (1.8 ** o); ph = rng.uniform(0, 6.28, 2) + v += amp * np.sin(px * a * 3 + np.sin(py * b * 3 + ph[0]) * 1.2) * np.cos(py * c * 3 + np.cos(px * d * 3 + ph[1]) * 1.2) + amp *= 0.45 + return v + qx = n(x, y); qy = n(x + 5.2, y + 1.3) + v = n(x + 0.9 * qx, y + 0.9 * qy) + v = (np.sin(v * bands * math.pi) + 1) / 2 + cs = np.array([hexc(c)[:3] for c in cols], float) + seg = len(cs) - 1 + k = np.clip((v * seg).astype(int), 0, seg - 1); u = (v * seg - k)[..., None] + arr = (cs[k] * (1 - u) + cs[k + 1] * u).astype(np.uint8) + im = Image.fromarray(arr).resize((S, S), Image.BICUBIC).filter(ImageFilter.GaussianBlur(3)) + return im.convert('RGBA') + +def grain(img, amt=10, seed=0): + rng = np.random.default_rng(seed) + a = np.array(img.convert('RGB')).astype(np.int16) + rng.normal(0, amt, (S, S, 1)) + return Image.fromarray(np.clip(a, 0, 255).astype(np.uint8)).convert('RGBA') + +# ---------- type +def mixed_line(txt, fonts, size, fill, offset=0, stroke=0, stroke_fill=None, tracking=0): + """render txt letter-by-letter cycling fonts; returns tight RGBA image""" + W = int(size * len(txt) * 1.3) + 200; H = int(size * 1.9) + im = layer(W, H); d = ImageDraw.Draw(im) + x = 40; base = int(size * 1.35) + for i, ch in enumerate(txt): + f = F(fonts[(i + offset) % len(fonts)], size) + d.text((x, base), ch, font=f, fill=hexc(fill) if isinstance(fill, str) else fill, anchor='ls', + stroke_width=stroke, stroke_fill=hexc(stroke_fill) if stroke_fill else None) + x += f.getlength(ch) + tracking + bb = im.getbbox() + return im.crop(bb) if bb else im + +def fit_w(im, w, h=None): + r = w / im.width + nh = int(im.height * r) if h is None else h + return im.resize((int(w), max(1, nh)), Image.LANCZOS) + +def stack(name, fonts, colors, rows, width=S - 60, gap=12, size=260, stroke=0, stroke_fill=None, rowh=None): + """rows of the name, each stretched to full width, fonts rotating per row""" + lines = [] + for r in range(rows): + ln = mixed_line(name, fonts, size, colors[r % len(colors)], offset=r, stroke=stroke, stroke_fill=stroke_fill) + lines.append(fit_w(ln, width, rowh)) + tot = sum(l.height for l in lines) + gap * (rows - 1) + out = layer(S, max(S, tot)) + y = (out.height - tot) // 2 + for l in lines: + out.alpha_composite(l, ((S - l.width) // 2, y)); y += l.height + gap + if out.height > S: + out = out.crop((0, (out.height - S) // 2, S, (out.height - S) // 2 + S)) + return out + +# ---------- warps +def warp(img, kind, strength=1.0, **kw): + a = np.array(img) + h, w = a.shape[:2] + y, x = np.mgrid[0:h, 0:w].astype(np.float32) + cx, cy = w / 2, h / 2 + if kind == 'sphere': + nx, ny = (x - cx) / (w / 2), (y - cy) / (h / 2) + r = np.sqrt(nx ** 2 + ny ** 2); rad = kw.get('rad', 0.95) + m = r < rad + rn = r / rad + f = np.where(m, np.power(np.clip(rn, 1e-6, 1), strength) / np.clip(rn, 1e-6, 1), 1) + mx = cx + nx * f * (w / 2); my = cy + ny * f * (h / 2) + elif kind == 'wave': + A, L = kw.get('amp', 40) * strength, kw.get('wl', 260) + mx = x + A * np.sin(y / L * 2 * math.pi + kw.get('ph', 0)) + my = y + A * 0.6 * np.sin(x / (L * 1.3) * 2 * math.pi) + elif kind == 'swirl': + dx, dy = x - cx, y - cy; r = np.sqrt(dx ** 2 + dy ** 2); R = w * 0.6 + th = np.arctan2(dy, dx) + strength * np.clip(1 - r / R, 0, 1) ** 2 + mx = cx + r * np.cos(th); my = cy + r * np.sin(th) + elif kind == 'bulge_x': + nx = (x - cx) / (w / 2) + mx = cx + np.sign(nx) * np.abs(nx) ** strength * (w / 2); my = y + out = cv2.remap(a, mx.astype(np.float32), my.astype(np.float32), cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0)) + return Image.fromarray(out) + +def sparkles(img, n, col='#ffffff', seed=0, smin=18, smax=55): + rnd = random.Random(seed); l = layer(); d = ImageDraw.Draw(l) + for _ in range(n): + x, y, s = rnd.uniform(40, S - 40), rnd.uniform(40, S - 40), rnd.uniform(smin, smax) + t = s * 0.16 + d.polygon([(x, y - s), (x + t, y - t), (x + s, y), (x + t, y + t), (x, y + s), (x - t, y + t), (x - s, y), (x - t, y - t)], fill=hexc(col)) + return Image.alpha_composite(img, l) + +def shadowed(base, lay, off=(10, 12), col=(0, 0, 0, 120), blur=10): + a = lay.getchannel('A'); sh = Image.new('RGBA', lay.size, col); sh.putalpha(a.point(lambda v: v * col[3] // 255)) + s2 = layer(); s2.alpha_composite(sh, off) + return Image.alpha_composite(Image.alpha_composite(base, s2.filter(ImageFilter.GaussianBlur(blur))), lay) + +# ---------- layouts +def L_sphere(name, bg, fonts, colors, rows=4, strength=1.8, stroke=0, stroke_fill=None, rad=0.98, size=240): + t = stack(name, fonts, colors, rows, width=S - 40, gap=6, size=size, stroke=stroke, stroke_fill=stroke_fill) + t = warp(t, 'sphere', strength, rad=rad) + return shadowed(bg, t) + +def L_stack(name, bg, fonts, colors, rows=7, wave=0, stroke=0, stroke_fill=None, gap=8, hl=None, hlcol=None, size=240): + cols = list(colors) + t = stack(name, fonts, cols, rows, width=S - 50, gap=gap, size=size, stroke=stroke, stroke_fill=stroke_fill) + if hl is not None: + t2 = stack(name, fonts, [hlcol] * rows, rows, width=S - 50, gap=gap, size=size) + # replace one row: crude—overlay highlight rows band + hh = t.height / rows + band = t2.crop((0, int(hl * hh), S, int((hl + 1) * hh))) + clear = Image.new('RGBA', (S, band.height), (0, 0, 0, 0)); t.paste(clear, (0, int(hl * hh))); t.alpha_composite(band, (0, int(hl * hh))) + if wave: t = warp(t, 'wave', 1, amp=wave, wl=520) + return shadowed(bg, t, blur=6) + +def L_diag(name, bg, fonts, fill, stroke_fill, rows=7, angle=-18, stroke=10, spark='#ffffff', seed=0): + big = layer(int(S * 1.6), int(S * 1.6)) + name = name if len(name) >= 6 else ' '.join([name] * 3) + lines = [fit_w(mixed_line(name, fonts, 230, fill, offset=r, stroke=stroke, stroke_fill=stroke_fill), int(S * 1.5)) for r in range(rows)] + y = (big.height - sum(l.height for l in lines) - 10 * rows) // 2 + for i, l in enumerate(lines): + big.alpha_composite(l, ((big.width - l.width) // 2 + (60 if i % 2 else -60), y)); y += l.height + 10 + big = big.rotate(angle, resample=Image.BICUBIC) + o = (big.width - S) // 2; t = big.crop((o, o, o + S, o + S)) + t = warp(t, 'wave', 1, amp=22, wl=700) + img = shadowed(bg, t, off=(14, 16), col=(0, 0, 0, 140)) + return sparkles(img, 12, spark, seed) + +def L_vertical(name, bg, fonts, colors, rows=5, wave=30, stroke=0, stroke_fill=None): + t = stack(name, fonts, colors, rows, width=S - 40, gap=4, size=240, stroke=stroke, stroke_fill=stroke_fill) + t = t.rotate(90, resample=Image.BICUBIC) + t = warp(t, 'wave', 1, amp=wave, wl=600) + return shadowed(bg, t, off=(8, 8), col=(0, 0, 0, 150), blur=6) + +def L_script(name, bg, font, fill, stroke_fill, motif=None, seed=0, y=470, amp=45, line2=None, font2=None): + t = layer() + ln = fit_w(mixed_line(name, [font], 300, fill, stroke=12, stroke_fill=stroke_fill), S - 90) + if line2: + l2 = fit_w(mixed_line(line2, [font2 or font], 300, fill, stroke=12, stroke_fill=stroke_fill), S - 260) + tot = ln.height + l2.height + 10 + t.alpha_composite(ln, ((S - ln.width) // 2, y - tot // 2)); t.alpha_composite(l2, ((S - l2.width) // 2, y - tot // 2 + ln.height + 10)) + else: + t.alpha_composite(ln, ((S - ln.width) // 2, y - ln.height // 2)) + t = warp(t, 'wave', 1, amp=amp, wl=900, ph=1.0) + img = shadowed(bg, t, off=(10, 12), col=(0, 0, 0, 120)) + if motif: img = motif(img) + return img + +def L_tile(name, cols_pairs, fonts, rows=9, seed=0, hl=None, hlpair=None): + """each letter in its own cell, cell bg/fg alternating like a checkerboard""" + img = Image.new('RGBA', (S, S), hexc(cols_pairs[0][0])); d = ImageDraw.Draw(img) + n = len(name); cw = S / n; ch = S / rows + for r in range(rows): + for i, c in enumerate(name): + bgc, fgc = cols_pairs[(i + r) % len(cols_pairs)] + if hl is not None and r == hl: bgc, fgc = hlpair[(i + r) % 2] + x0, y0 = i * cw, r * ch + d.rectangle((x0, y0, x0 + cw + 1, y0 + ch + 1), fill=hexc(bgc)) + if c == ' ': continue + f = F(fonts[(i * 3 + r) % len(fonts)], int(ch * 1.25)) + g = layer(int(cw * 3), int(ch * 3)); gd = ImageDraw.Draw(g) + gd.text((g.width / 2, g.height / 2), c, font=f, fill=hexc(fgc), anchor='mm') + bb = g.getbbox() + if not bb: continue + g = g.crop(bb).resize((int(cw * 0.96), int(ch * 0.92)), Image.LANCZOS) + img.alpha_composite(g, (int(x0 + cw * 0.02), int(y0 + ch * 0.04))) + return img + +# ---------- motifs +def flower_row(cols, y=1010, seed=0): + def f(img): + l = layer(); d = ImageDraw.Draw(l); rnd = random.Random(seed) + x = 70 + while x < S + 60: + r = rnd.uniform(70, 95); c1, c2 = rnd.sample(cols, 2) + for k in range(12): + a = 2 * math.pi * k / 12 + d.ellipse((x + r * 0.62 * math.cos(a) - r * 0.36, y + r * 0.62 * math.sin(a) - r * 0.36, x + r * 0.62 * math.cos(a) + r * 0.36, y + r * 0.62 * math.sin(a) + r * 0.36), fill=hexc(c1), outline=hexc('#1a0a0a'), width=4) + d.ellipse((x - r * 0.4, y - r * 0.4, x + r * 0.4, y + r * 0.4), fill=hexc(c2), outline=hexc('#1a0a0a'), width=4) + x += r * 1.75 + return Image.alpha_composite(img, l) + return f + +def sun_row(col, y=1020): + def f(img): + l = layer(); d = ImageDraw.Draw(l) + for x in range(100, S + 100, 250): + d.ellipse((x - 80, y - 80, x + 80, y + 80), fill=hexc(col), outline=hexc('#1a0a0a'), width=6) + for k in range(12): + a = 2 * math.pi * k / 12 + d.line((x + 95 * math.cos(a), y + 95 * math.sin(a), x + 125 * math.cos(a), y + 125 * math.sin(a)), fill=hexc('#1a0a0a'), width=8) + return Image.alpha_composite(img, l) + return f + +def mountain_row(cols, y=1000): + def f(img): + l = layer(); d = ImageDraw.Draw(l) + for k, c in enumerate(cols): + pts = [(0, S)] + [(x, y - 40 + k * 60 - 90 * abs(math.sin(x / (170 + k * 40) + k))) for x in range(0, S + 20, 20)] + [(S, S)] + d.polygon(pts, fill=hexc(c)) + return Image.alpha_composite(img, l) + return f + +SERIF = ['abril', 'pfital', 'dmserif', 'instital', 'playfair'] +BOLD = ['anton', 'bowlby', 'archivo', 'bungee', 'bebas'] +MIX = ['abril', 'anton', 'pfital', 'archivo', 'lobster', 'dmital', 'bowlby'] +FUN = ['shrikhand', 'righteous', 'lobster', 'bungee', 'pacifico'] + +COVERS = [ + ('arabesque', lambda: L_tile('Arabesque', [('#0d3b2e', '#e8c26a'), ('#e8c26a', '#0d3b2e')], ['abril', 'pfital', 'anton', 'dmital', 'archivo'], rows=9, hl=4, hlpair=[('#7a1f2b', '#f6e7c1'), ('#f6e7c1', '#7a1f2b')])), + ('french vibes', lambda: L_diag('French Vibes', vgrad(['#1d2bff', '#7b2cff', '#ff3d8b']), MIX, '#ffffff', '#120a3a', seed=2)), + ('café parisien', lambda: grain(L_stack('Café Parisien', vgrad(['#f4dcc4', '#e8bfa0']), SERIF, ['#7a1424', '#2a1410', '#7a1424', '#2a1410'], rows=7, wave=18, size=220), 8)), + ('in my feels', lambda: L_sphere('in my feels', marble(['#0b1a4a', '#2f5fb3', '#a8c8ff', '#2f5fb3', '#0b1a4a'], 4), ['pfital', 'instital', 'dmital', 'lobster'], ['#ffffff', '#dfe9ff'], rows=4, strength=1.9)), + ('pre-workout', lambda: grain(L_stack('PRE-WORKOUT', Image.new('RGBA', (S, S), hexc('#0b0b0b')), BOLD, ['#ff1f2d', '#ffffff'], rows=9, wave=0, gap=6, size=220), 16)), + ('rap throwbacks', lambda: L_vertical('Rap Throwbacks', marble(['#3a1a06', '#c8661c', '#f2b84a', '#c8661c', '#3a1a06'], 6), ['bowlby', 'abril', 'anton', 'lobster', 'archivo'], ['#fff3d6', '#1a0a00'], rows=5, wave=28)), + ('pop on repeat', lambda: L_sphere('Pop on Repeat', marble(['#ff3d8b', '#ff9a3c', '#ffe066', '#ff9a3c', '#ff3d8b'], 7), FUN, ['#ffffff', '#3a0a2a'], rows=4, strength=1.7, stroke=0)), + ('throwback pop', lambda: L_diag('Throwback Pop', vgrad(['#ff7ad9', '#8f5bff', '#2bd4ff']), FUN, '#ffffff', '#2a0f6b', angle=16, seed=8)), + ('3am thoughts', lambda: grain(L_stack('3am thoughts', vgrad(['#05060f', '#141a3a']), ['vt323', 'pfital', 'anton', 'instital', 'rubikmono'], ['#c7d1ff', '#5a6799'], rows=8, wave=22, hl=3, hlcol='#ff4d6d', size=220), 10)), + ('festival mode', lambda: L_sphere('Festival Mode', marble(['#0a0014', '#ff2bd6', '#2bf0ff', '#8f5bff', '#0a0014'], 10, bands=4), ['monoton', 'bungee', 'righteous', 'anton'], ['#ffffff', '#0a0014'], rows=4, strength=1.35)), + ('stadium anthems', lambda: grain(L_stack('STADIUM ANTHEMS', vgrad(['#0b1430', '#26357a']), ['bebas', 'anton', 'bowlby', 'archivo'], ['#ffc94d', '#ffffff'], rows=8, wave=0, size=220), 10)), + ('indie sunsets', lambda: grain(L_script('indie', vgrad(['#2b1b4f', '#c2477a', '#ff8a5c', '#ffd36e']), 'lobster', '#fff1e0', '#2b1b4f', motif=sun_row('#ffd36e'), y=470, line2='sunsets'), 8)), + ('lofi study', lambda: L_tile('lofi study', [('#c9b8e8', '#3d2f5a'), ('#f3e6f0', '#6b5a8a')], ['vt323', 'instital', 'righteous', 'dmital'], rows=9)), + ('underwater', lambda: L_sphere('Underwater', marble(['#1b2233', '#4a5a7a', '#c8b6d9', '#4a5a7a', '#1b2233'], 31), ['instital', 'pfital', 'dmital', 'lobster'], ['#f2ecff', '#c8b6d9'], rows=5, strength=1.4)), + ('bollywood nights', lambda: L_script('Bollywood', marble(['#5a0533', '#e0287a', '#ffb300', '#e0287a', '#5a0533'], 14), 'yatra', '#ffd54f', '#3a0322', motif=flower_row(['#ffb300', '#ff5e3a', '#e0287a', '#ffe066'], seed=14), y=450, line2='Nights', font2='lobster')), + ('reggaetón', lambda: L_vertical('Reggaetón', marble(['#7a0019', '#ff2d2d', '#ffb31f', '#ff2d2d', '#7a0019'], 15), ['archivo', 'lobster', 'bowlby', 'pfital'], ['#ffe14d', '#14001f'], rows=5, wave=34)), + ('afrobeats', lambda: L_vertical('Afrobeats', marble(['#0b5e33', '#f2b705', '#d62828', '#111111', '#0b5e33'], 16, bands=4), ['bowlby', 'abril', 'anton', 'shrikhand'], ['#ffffff', '#ffffff'], rows=5, wave=30)), + ('afro house', lambda: L_sphere('Afro House', marble(['#4a1a0c', '#e07a3f', '#f6d7a7', '#e07a3f', '#4a1a0c'], 17), ['abril', 'anton', 'pfital', 'bowlby'], ['#2a0f08', '#fff3e0'], rows=5, strength=1.35)), + ('uk rap', lambda: L_tile('UK RAP', [('#111111', '#d9d9d9'), ('#d9d9d9', '#111111')], ['archivo', 'anton', 'bowlby', 'abril'], rows=7, hl=3, hlpair=[('#c6ff3d', '#111111'), ('#111111', '#c6ff3d')])), + ('late night r&b', lambda: L_diag('R&B', vgrad(['#1a0533', '#6a1b9a', '#ff4fa3']), ['abril', 'pfital', 'anton'], '#ffffff', '#1a0533', rows=8, angle=-22, seed=19)), + ('golden oldies', lambda: grain(L_stack('Golden Oldies', vgrad(['#4a0c18', '#2a060e']), SERIF, ['#f2cf7a', '#c9963a'], rows=8, wave=14, size=220), 9)), + ('back roads', lambda: grain(L_script('Back', vgrad(['#3b2a5a', '#c9725b', '#f2a65a', '#f7d9a8']), 'rye', '#fff3d6', '#3d2632', motif=mountain_row(['#9c4f4a', '#6b3a3f', '#3d2632'], y=980), y=430, amp=30, line2='Roads'), 10)), + ('smoky jazz bar', lambda: L_sphere('Smoky Jazz', marble(['#03100f', '#123536', '#3d6b5e', '#123536', '#03100f'], 22), ['pfital', 'abril', 'dmital', 'instital'], ['#f6e7b8', '#e0b95a'], rows=4, strength=1.4, stroke=6, stroke_fill='#03100f')), + ('island time', lambda: grain(L_script('island', vgrad(['#7fd3ff', '#b8f0c8', '#ffe8a3']), 'pacifico', '#ffd400', '#0b6b39', motif=lambda im: flower_row(['#e0262c', '#ffd400', '#1a9e4b'], seed=23)(im), y=450, line2='time'), 8)), + ('pop punk', lambda: L_tile('POP PUNK', [('#ff4fae', '#111111'), ('#111111', '#ff4fae')], ['anton', 'bowlby', 'caveat', 'archivo', 'bungee'], rows=8, hl=4, hlpair=[('#ffffff', '#111111'), ('#111111', '#ffffff')])), +] + +def slug(n): return n.replace(' + ', '-and-').replace(' ', '-').replace('&', 'and').replace('é', 'e').replace('ó', 'o') + +def save(img, name): + img = img.convert('RGB').resize((1000, 1000), Image.LANCZOS); q = 92 + while True: + b = io.BytesIO(); img.save(b, 'JPEG', quality=q, optimize=True, progressive=True) + if b.tell() < 250_000 or q <= 60: break + q -= 4 + open(os.path.join(OUT, slug(name) + '.jpg'), 'wb').write(b.getvalue()); return b.tell(), q + +if __name__ == '__main__': + only = sys.argv[1:] + for n, fn in COVERS: + if only and n not in only: continue + sz, q = save(fn(), n); print(f'{n:18s} {sz // 1024}KB q{q}') + T = 330; sheet = Image.new('RGB', (6 * T, 4 * T), 'white') + for i, (n, _) in enumerate(COVERS): + p = os.path.join(OUT, slug(n) + '.jpg') + if os.path.exists(p): sheet.paste(Image.open(p).resize((T - 10, T - 10)), ((i % 6) * T + 5, (i // 6) * T + 5)) + sheet.save(os.path.join(OUT, 'all-covers-preview.jpg'), quality=85) diff --git a/sorter/tools/from_page.py b/sorter/tools/from_page.py new file mode 100644 index 0000000..708ebea --- /dev/null +++ b/sorter/tools/from_page.py @@ -0,0 +1,23 @@ +"""Restore data/playlists.json and data/state.json from a built page (site/index.html or the published copy). +Usage: python tools/from_page.py path/to/page.html +The playlist data lives only in the published page (it's kept out of git), so a new session starts by saving +the published page locally and running this.""" +import json, os, re, sys +R = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') + +html = open(sys.argv[1], encoding='utf-8').read() +page = json.loads(re.search(r'const DATA=(\[.*?\]);\n', html, re.S).group(1)) +m = re.search(r'const STATE=(\{.*?\});\n', html, re.S) +state = json.loads(m.group(1)) if m else {} + +data = [] +for p in page: + tracks = [[t['u'].rsplit('/', 1)[-1], t['n'], t['a']] + [f for f in ('new', 'fresh') if t.get(f)] for t in p['tracks']] + data.append({'name': p['name'], 'desc': p['desc'], 'spotify_description': p['sd'], + 'recs': [[x['n'], x['a']] for x in p['recs']], 'tracks': tracks}) + +os.makedirs(os.path.join(R, 'data'), exist_ok=True) +json.dump(data, open(os.path.join(R, 'data/playlists.json'), 'w', encoding='utf-8'), ensure_ascii=False, separators=(',', ':')) +if state: + json.dump(state, open(os.path.join(R, 'data/state.json'), 'w', encoding='utf-8'), indent=1) +print('restored', len(data), 'playlists,', sum(len(p['tracks']) for p in data), 'songs; state:', state or 'none in page') diff --git a/sorter/tools/genres.py b/sorter/tools/genres.py new file mode 100644 index 0000000..3f3c691 --- /dev/null +++ b/sorter/tools/genres.py @@ -0,0 +1,43 @@ +G = {} +def add(g, s): + for a in s.split('|'): + a = a.strip() + if a: G[a] = g + +add('RAP', """Drake|Post Malone|NF|Juice WRLD|Mac Miller|Lil Dicky|Kendrick Lamar|Future|Kanye West|Lil Nas X|Cardi B|Nicki Minaj|Metro Boomin|Young Thug|Eminem|Travis Scott|Macklemore|Lil Tjay|Swae Lee|Gunna|Lil Uzi Vert|Pop Smoke|Lil Wayne|DaBaby|Tory Lanez|Quavo|24kGoldn|Jack Harlow|The Kid LAROI|21 Savage|XXXTENTACION|KYLE|Playboi Carti|Kid Cudi|Wiz Khalifa|Fetty Wap|Logic|Chance the Rapper|DJ Khaled|Iggy Azalea|Dave|Rick Ross|Lil Yachty|Migos|Tyler|The Creator|Offset|Kodak Black|Polo G|J. Cole|Macklemore & Ryan Lewis|Ryan Lewis|Roddy Ricch|Big Sean|French Montana|Gucci Mane|A$AP Rocky|Trippie Redd|Lil Tecca|G-Eazy|Lil Baby|Coi Leray|Snoop Dogg|Lil Durk|iann dior|Internet Money|Skepta|Yeat|WizTheMc|Tommy Richman|Baby Keem|Dr. Dre|JAŸ-Z|YG|50 Cent|Rich Amiri|¥$|Rich The Kid|Central Cee|Jay Rock|Young Nudy|Megan Thee Stallion|Russ|G Herbo|YoungBoy Never Broke Again|JID|070 Shake|Octavian|Ice Cube|StaySolidRocky|Childish Gambino|Outkast|K'NAAN|SoFaygo|R. City|Yxng Bane|Flipp Dinero|Lil Mosey|Blueface|SAINt JHN|Tyga|Lil Pump|Stormzy|YNW Melly|NAV|Freddie Gibbs|The Alchemist|Lucki|Kardinal Offishall|Three 6 Mafia|Young Buck|8Ball & MJG|2Pac|B.o.B|BigXthaPlug|Sir Mix-A-Lot|Rich Homie Quan|Lefty Gunplay|Little Simz|Jibbs|AJ Tracey|Kenny Beats|Pusha T|Meek Mill|Sleepy Hallow|Doechii|Mike Jones|Paul Wall|Slim Thug|The Notorious B.I.G.|Atmosphere|Slug|Ant|J. Davi$|"DAVE"|ScHoolboy Q|A Boogie Wit da Hoodie|Remy Boyz|Young Stoner Life|Strick|Lecrae|Nelly|J Hus|Yung Gravy|Plies|$NOT|LRN Slime|Armani White|Takeoff|03 Greedo|Lil Keed|$uicideboy$|Yung Fazo|D-Block Europe|Yung Lean|Saweetie|Masked Wolf|Swizz Beatz|Eve|Grandmaster Melle Mel|Grandmaster Caz|Kool Moe Dee|Chamillionaire|Krayzie Bone|6ix9ine|Lil Jon|Juicy J|2 Chainz|Yungen|Nate Dogg|E-40|Chiddy Bang|mgk|Ludacris|DigDat|bbno$|Wale|Smino|T.I.|Big Shaq|Desiigner|Rittz|DMX|Soulja Boy|Nas|Arizona Zervas|Bun B|Rich Boy|Mario Judah|Coolio|L.V.|Mustard|Jaden|BVG|Kojo Funds|Ricky Rich|Dardan|Hotel Ugly|Connor Price|Big Brev|Blxst|Rob $tone|J Spooks|ZieZie|Atom Swag|Dave B.|Travis Thompson|Lil Cobaine|LL Clawz|507KAZ|TWXN|Hollis|Desiigner|Fast & Furious: The Fast Saga|Ty Dolla $ign|Don Toliver|A-Wall|Kent Jones|Drex Carter|Yatta Bandz|MarMar Oso|lil Shordie Scott|KillBunk|Krsna Solo""") + +add('ANTHEM', """Coldplay|Imagine Dragons|OneRepublic|AJR|Bastille|Twenty One Pilots|X Ambassadors|Panic! At The Disco|Brendon Urie|fun.|Fall Out Boy|KALEO|Of Monsters and Men|Tom Walker|Lukas Graham|5 Seconds of Summer|The Vamps|Queen|Red Hot Chili Peppers|Nirvana|Survivor|Foreigner|Journey|The Score|Walk off the Earth|WALK THE MOON|Neon Trees|Portugal. The Man|Foster The People|Cage The Elephant|Young the Giant|The Offspring|Green Day|Paramore|BANNERS|Alex Clare|Imagine Dragons|Sam Fender|Keane|Florence + The Machine|Pearl Jam|Soundgarden|Judas Priest|Whitesnake|Billy Idol|Europe|Kenny Loggins|Plain White T's|Train|Owl City|Saint Motel|Milky Chance|The Goo Goo Dolls|Matt and Kim|Mother Mother|lovelytheband|A R I Z O N A|SHAED|Rag'n'Bone Man|Dermot Kennedy|Kodaline|Amber Run|The Proclaimers|Two Door Cinema Club|MGMT|The Stranglers|Mr. Mister|YUNGBLUD|Oliver Tree|Hippie Sabotage|Rivers Cuomo|Jon Bellion|Empire Of The Sun|Tally Hall|Fontaines D.C.""") + +add('EDM', """Avicii|Marshmello|The Chainsmokers|Clean Bandit|David Guetta|Kygo|Zedd|Major Lazer|Martin Garrix|Calvin Harris|DJ Snake|Diplo|Cheat Codes|Alesso|Jonas Blue|Jax Jones|Alan Walker|Galantis|ILLENIUM|Lost Frequencies|Sigala|Gryffin|Robin Schulz|Willy William|Imanbek|Dante Klein|Disclosure|Loud Luxury|Sandro Cavazza|Grey|Gesaffelstein|Elderbrook|Bakermat|Netsky|Kavinsky|Martin Solveig|Riton|Kah-Lo|Benjamin Diamond|Alan Braxe|Thomas Bangalter|Stardust|Edward Maya|Vika Jigulina|M83|Monty Datta|MEDUZA|Goodboys|Shouse|Modjo|Sigma|Nicky Romero|Krakn|DVBBS|Shaun Frank|Seeb|AronChupa|Little Sis Nora|AFROJACK|Kiesza|Dynoro|GIGI D'AGOSTINO|Tiësto|Dzeko|Lost Kings|Silk City|Petit Biscuit|Surf Mesa|Emilee|Heuse|Ryan Riback|Topic|Matoma|Sam Feldt|Digital Farm Animals|Axwell /\\ Ingrosso|Axwell|Sebastian Ingrosso|Cedric Gervais|LMFAO|Lauren Bennett|GoonRock|Mike Posner|Icona Pop|Technotronic|Yves V|Ilkay Sencan|R3HAB|Manila Killa|Madden|Tez Cadey|Felax|Kenya Grace|Il Neige|NEIKED|Ratatat|Jungle|Gigamesh|Justin Jesso|Mounika.|HUTS|Hugo|Riton|Kungs|Dj Yo!|AX'EL|Paris Boy|CHROMANCE|Valentina Franco""") + +add('POP', """Charlie Puth|Sia|Justin Bieber|Billie Eilish|Ed Sheeran|Selena Gomez|Bruno Mars|Ariana Grande|Anne-Marie|Camila Cabello|Bebe Rexha|Doja Cat|Dua Lipa|Halsey|Demi Lovato|Maroon 5|Rihanna|Shawn Mendes|Meghan Trainor|benny blanco|Taylor Swift|Alessia Cara|Jason Derulo|Little Mix|Akon|Katy Perry|Ellie Goulding|Sigrid|Jess Glynne|Grace VanderWaal|RAYE|One Direction|Julia Michaels|Tones And I|Olly Murs|Fifth Harmony|Liam Payne|Pitbull|Black Eyed Peas|Lady Gaga|Tate McRae|Benson Boone|Daya|Rita Ora|Emily Warren|Mabel|Sam Smith|Hailee Steinfeld|Adele|Gwen Stefani|JVKE|Charli xcx|Elley Duhé|Olly Alexander (Years & Years)|Sean Paul|Natasha Bedingfield|Nelly Furtado|Céline Dion|Troye Sivan|Harry Styles|Jennifer Lopez|John Newman|Ruth B.|Lizzo|Alec Benjamin|DNCE|Paloma Faith|MKTO|Kesha|Andy Grammer|Normani|Christina Aguilera|ZAYN|Olivia Rodrigo|Duncan Laurence|MARINA|Sabrina Carpenter|Jessie J|Carly Rae Jepsen|Sophie Ellis-Bextor|JENNIE|Madison Beer|Backstreet Boys|*NSYNC|Wham!|Enrique Iglesias|RedOne|Teddy Sky|P!nk|The Wanted|PSY|Christina Perri|OMI|Ella Eyre|Tove Lo|Jason Mraz|Fergie|Rixton|Alexandra Stan|Lou Bega|Justin Timberlake|Louis Tomlinson|Janieck Devy|Mary Lambert|Maggie Lindemann|A Great Big World|MAGIC!|Lennon Stella|Jonas Brothers|Selena Gomez & The Scene|Cher Lloyd|Ava Max|Niall Horan|Louisa Johnson|Kelsea Ballerini|Conor Maynard|Taio Cruz|Iyaz|Jawsh 685|Sean Kingston|Ally Brooke|BLACKPINK|FIFTY FIFTY|Bella Poarch|Calum Scott|Agnes|Au/Ra|Ina Wroldsen|Starley|Phoebe Ryan|Alexander Rybak|Stefflon Don|Becky G|Hayley Williams|Royal & the Serpent|Tomine Harket|Noonie Bao|FLETCHER|Mae Muller|Rosa Linn|Lola Young|Ashe|Cat Burns|Lykke Li|Dido|Indila|Christine and the Queens|Superfruit|vaultboy|salem ilese|Gracie Abrams|Lily-Rose Depp|Haley Reinhart|Stefanie Scott|George Ezra|Bazzi|Lewis Capaldi|James Arthur|Dean Lewis|Lauv|Ali Gatie|Alex Warren|Teddy Swims|Powfu|Tai Verdes|BoyWithUke|Chance Peña|JP Saxe|Sam Fischer|gnash|Olivia O'Brien|Jeremy Zucker|Sasha Alex Sloan|Social House|Quinn XCII|blackbear|Trevor Daniel|Kina|d4vd|Jake Etheridge|Ayelle|Justin Timberlake|Sara Kays|Rendell Stovall|Mackenzy Mackay|Matt Maltese|Cartoon|Daniel Levi|Sharna Bass|Covered Up|Conrad Sewell|Stanaj|James Bay|Maty Noyes|William Singe|Naughty Boy|Leah Culver|Brando|Parson James|Dan Caplen|ROZES|Minke|Annika Wells|Kerli|Winona Oak|Jack & Jack|sirenXX|Iselin|Theresa Rex|Y2K|Pop Money|Sad Pilot|nalin|Marlon Funaki""") + +add('RNB', """Khalid|The Weeknd|Brent Faiyaz|Chris Brown|SZA|Steve Lacy|Daniel Caesar|GIVĒON|6LACK|Kehlani|Bryson Tiller|Summer Walker|Jessie Reyez|Kali Uchis|Beyoncé|Keyshia Cole|Leon Thomas|Ebony Loren|Olivia Dean|Sade|Ne-Yo|Masego|H.E.R.|Jeremih|PARTYNEXTDOOR|PnB Rock|John Legend|Chrisette Michele|Ravyn Lenae|Ray J|Victoria Monét|Mary J. Blige|Kelly Rowland|Estelle|Frank Ocean|Ciara|USHER|Alicia Keys|Jennifer Hudson|Robin Thicke|Charlie Wilson|Bobby Caldwell|Mahalia|Drew Love|Diana Gordon|Snoh Aalegra|Thee Sacred Souls|Tre' Amani|Toian|Khary|Iyanii|Braelyn Rankins|Theo Somolu|Jamie Foxx|Timbaland|Janelle Monáe|Serayah|Chase Atlantic|Jidenna|Aloe Blacc|CeeLo Green|Rotimi""") + +add('LOFI', """Shiloh Dynasty|slō|twilight|Lofi Fruits Music|Chill Fruits Music|timmies|LofiCentral|sped up + slowed|untrusted|11:11 Music Group|Avocuddle|Sarcastic Sounds|90sFlav|lofi.samurai|kidolitx|Lofi Afrobeats|Lofi Cloud|Lo'fi Boy|Kauai45|Sweet Cocoa|khai dreams|Fudasca|Hashir|Promoting Sounds|Ouse|Krono|Luvli|Snøw|cøzybøy|SEB|Teqkoi|Aiko|Lilorx|Akano|creamy|RH Music|Orange Stick|ROSE BEAT|Bens|Apch|Nova|Nej|Cryst|slater|slowo|Super Pipo|Kyla|Sista Prod|Rxseboy|pretence|Closed on Sunday|Linearwave|Latverzyla|Jan Metternich|Skinny Atlas|prxz|Rudy Boy|EZRA|Eduardo Luzquiños|Don Blaine|Snuggles|owlh|Diego Mcn|Sody|BEAM|Famy|Chasinbens|Lewis Hanton|GenriX|Maxgonz|sagun|Emie|Dibyo|itssvd|Austin George|Steelix|Philkeyz|Harmless|Nvr/Mnd|unheard|Boy In Space|Brooksie|SHAUN|Baby Land|Elohim|Joe Janiak|Spectrum|NOJO|I Am Roze|Joy Again|novaly|Xcozso|Aden Foyer|Bobby Bass|Eric Hollaway|Ebucs|Jesse Elkins|Davide Dalmonte|Mark Keali'i Ho'omalu|8D Tunes|Fancy Fingers|Lilmaina|Harry Craze|noevdv|3slow2|Speed Radio|NITESHIFT|Absofacto|Flamingos in the Tree|Carter Ace|Jason LaPierre|RC AVENUE|8RO8|ZEDDY WILL|EI8HT|Yoko Gold|Tee|Aarya Chakradhari|Antara Mitra|Il Neige|anees|Yuppe|S.N.E|Tangerine Kitty|ISTERIF|George Moir|THE SCOTTS|monte booker|Olakira|M.O|Lotto Boyzz|Damian Lemar Hudson|Romy Dya|Steve Pitron|Max Sanna|Jamie Scott|Sonny Wern|Gang Speed|DripReport|Halcyon|Rhenzy Feliz|Kayymulla|Dhruv|omgkirby|Mike Sabath|MAX|Dwayne Johnson|Aislin Evans|Joni Fatora|Sharleen Ka|Little Sis Nora|Corporate Slackrs|Delaney Jane|6AM|The Him|Son Mieux|HeezaRascal|Max Borghetti|Masauti|Nyla|Jodie Harsh|Rexx Life Raj|Trobi|LePrince""") + +add('INDIE', """Dominic Fike|Rex Orange County|Joji|Tame Impala|Arctic Monkeys|beabadoobee|Malcolm Todd|TV Girl|The Backseat Lovers|Laufey|Beach House|Surf Curse|Ritt Momney|Glass Animals|Wallows|Royel Otis|The Marías|Men I Trust|Cafuné|almost monday|grentperez|sombr|Sufjan Stevens|Gigi Perez|Rainbow Kitten Surprise|Still Woozy|Chezile|Will Paquin|Alex G|Emily Yacina|Jack Stauber|Declan McKenna|Mac DeMarco|Dr. Dog|Ocean Alley|Cigarettes After Sex|rhianne|Bakar|Lava La Rue|Beach Weather|Oh Wonder|AREA21|Cavetown|FKJ|Patrick Watson|Surfaces|Clairo|Claire Rosinkranz|BØRNS|WILLOW|Vancouver Sleep Clinic|Dennis Lloyd|Bryce Vine|King Princess|Empress Of|Good Neighbours|Balu Brigada|Bryant Barnes|Temper City|Lithe|The Walters|Radical Face|Matt Maltese|Seafret|Passenger|Jessie Payo|Frenship|FRENSHIP|Hollis|The Chords|Kenya Grace|Mark Ambor|Lana Del Rey|serpentwithfeet|Jamiroquai|WILLIS|Isabel van Gelder|Postmodern Jukebox|Iration|Rusted Root|Natalia Lafourcade|Elyanna|Lorde|Oh The Larceny""") + +add('DESI', """Pritam|Vishal-Shekhar|Arijit Singh|Vishal Dadlani|Amitabh Bhattacharya|Sonu Nigam|Kumaar|Sunidhi Chauhan|Clinton Cerejo|Chinmayi|Atif Aslam|Shreya Ghoshal|Badshah|AP Dhillon|Shankar Mahadevan|Mika Singh|Shefali Alvares|Ash King|Roop Kumar Rathod|Sayeed Quadri|Shankar-Ehsaan-Loy|Shafqat Amanat Ali|Gopi Sundar|Charulatha Mani|Madhumitha Mani|Irshad Kamil|Harshdeep Kaur|Roman Banks|Prem & Hardeep|Amar Arshi|Neha Kakkar|Indeep Bakshi|Amrik Singh|Siedd|Taba Chake|Shinda Kahlon|Swanand Kirkire|Shaan|Suraj Jagan|Sharman Joshi|Amrita Kak|Yo Yo Honey Singh|Shekhar Ravjiani|Salim Merchant|Neeti Mohan|Prabh Singh|Jay Trak|Rooh Sandhu|Urban Byte|Mahalakshmi Iyer|Hard Kaur|Jassi|Anirudh Ravichander|Shilpa Rao|Anuv Jain|Dominique Cerejo|Loy Mendonsa|Sachet Tandon|Parampara Tandon|Sachet-Parampara|Pav Dharia|Harshavardhan Rameshwar|Manan Bhardwaj|Jon Caryl|Sona Mohapatra|Ram Sampath|Munna Dhiman|Diljit Dosanjh|thiarajxtt|Jasleen Royal|Dulquer Salmaan|Shalmali Kholgade|Mithoon|Neeraj Shridhar|Kavita Seth|Benny Dayal|Hamsika Iyer|Rashid Ali|Farhan Akhtar|Hrithik Roshan|Abhay Deol|Lehmber Hussainpuri|Raj Shekhar|Zack Knight|Monu music|Quail P|Krsna Solo|GaTa""") + +add('LATIN', """Bad Bunny|J Balvin|Ozuna|Daddy Yankee|Nicky Jam|Tainy|Shakira|El De Las R's|FloyyMenor|Cris MJ|ROSALÍA|Farruko|Luis Fonsi|Maluma|CNCO|Rauw Alejandro|Romeo Santos|Milo j|Taiu|Marc Anthony|Manuel Turizo|Prince Royce|JHAYCO|Anuel AA|Yandel|Juan Magán|Pedro Capó|TINI|Manu Chao|Carlinhos Brown|Los Del Rio|BLANCO|Sfera Ebbasta|melvitto|Carolina Gaitán - La Gaita|Mauro Castillo|Adassa|Diane Guerrero|Stephanie Beatriz|Encanto - Cast|Becky G|Roberto Burgos|Maria Del Mar Fernandez|Yandel|ElGrandeToto""") + +add('AFRO', """Burna Boy|Wizkid|Sauti Sol|CKay|Tems|Patoranking|Mr Eazi|P-Square|May D|Tyla|Asake|Diamond Platnumz|KiDi|Master KG|Nomcebo Zikode|Shimza|Fuse ODG|Victony|KTIZO|OMAH LAY|Camidoh|Mayorkun|Darkoo|King Promise|Tekno|Sarz|AZ Chike|TitoM|Nonso Amadi|Tambo|SPINALL|Fireboy DML|Pheelz|BNXN|Rayvanny|Kizz Daniel|Yemi Alade|Korede Bello|DJ Maphorisa|Nasty C|Ice Prince|Freshlyground|Ayo & Teo|NDOVU KUU|Fathermoh|Johnny Johnny|Nyashinski|Davido|Rema|Ayra Starr|Mohombi|David Walters|Kolinga|Booboo'zzz All Stars|Trinix|Trinix Remix|Odeal|Cloud Chief|Naïka|Kikimoteleba|Vegedream|Tiakola|Marwa Loud|Dadju|GoldFord|Henrik|CORBAL|L.Dre|Tazzy|Eduardo XD|Shea|ZionRay|Theo Somolu|Mckay|Big Z|Stellar|Akon""") +G['Akon'] = 'POP' + +add('CARIB', """Shaggy|Bob Marley & The Wailers|Tarrus Riley|Charly Black|Shenseea|Masicka|Di Genius|Jah Lil|Jaz Elise|Skillibeng|Konshens|iNi Kamoze|Johnny Nash|Snow|YG Marley|Kabaka Pyramid""") + +add('COUNTRY', """Noah Kahan|Ella Langley|Shaboozey|Myles Smith|The Wellermen|John Denver|Hozier|Kane Brown|Jelly Roll|Riley Green|Rascal Flatts|Bailey Zimmerman|Dan + Shay|Florida Georgia Line|Lord Huron|The Lumineers|Vance Joy|Jack Johnson|Billy Ray Cyrus|Blanco Brown|Maren Morris|Shy Carter|Forrest Frank|Richy Mitch & The Coal Miners|Mark Ambor|Michael Marcagi|Zach Bryan|Tyler Childers|Morgan Wallen|Luke Combs|Israel Kamakawiwo'ole|Tracy Chapman|Donovan|Sam Martin|Paul Russell|Edith Whiskers|Jason Weaver""") + +add('CLASSIC', """ABBA|Michael Jackson|The Jackson 5|War|Nena|Frank Sinatra|John Lennon|George Michael|Louis Armstrong|Grover Washington|Jr.|Bill Withers|Modern Talking|Elvis Presley|Cliff Richard & The Shadows|America|George Martin|Journey|Bobby Hebb|Sam Cooke|Marvin Gaye|Peggy Lee|The Beatles|Tom Petty|Harry Belafonte|Bee Gees|Lesley Gore|Billy Joel|Ray Charles|Fleetwood Mac|Belinda Carlisle|Michael Sembello|The Ink Spots|Paul Anka|Kenny Loggins|Ella Fitzgerald|Gloria Gaynor|Engelbert Humperdinck|Charles Trenet|Baltimora|Eurythmics|Annie Lennox|Dave Stewart|James Brown & The Famous Flames|Bobby McFerrin|Rick Astley|Phil Collins|Pink Floyd|Rod Stewart|Earth|Wind & Fire|Boney M.|Daryl Hall & John Oates|Kool & The Gang|Blue Swede|Björn Skifs|OMC|Verdine White|Paul McCartney|Roger|Michael Bublé|Queen|Europa|Julie Andrews|Nicholas Hammond|Debbie Turner|Duane Chase|Heather Menzies|Angela Cartwright|Kym Karath|Charmian Carr|The Beatles Revival Band & Orchestra|Survivor|Foreigner|Whitesnake|Judas Priest|Billy Idol|Europe|Mr. Mister|The Stranglers|Nirvana|Soundgarden|Pearl Jam|The Offspring|Green Day|Red Hot Chili Peppers""") +# Queen & classic rock -> CLASSIC overrides ANTHEM for those above (last add wins) + +add('ARABIC', """Khaled|Maher Zain|Issam Alnajjar|Muhammad Abduh|Abadi Al Johar|Sami Yusuf|Mohammed Assaf|Sherine|Mesut Kurtis|Elyanna|Mishaal Tamer|Hamza Namira""") +add('SKIP', """The Holy Quran|Mishari Rashid Alafasy|KaraokePro|Party Tyme Karaoke|Choir|Traditional|Kabusa Oriental Choir|Disney|Rowan Atkinson|Bo Burnham|JT Music|League of Legends|Arcane|Timothée Chalamet|Taron Egerton|Kamehameha Schools Children's Chorus|Laura Williams|F1 The Album""") +add('POP', "Pharrell Williams|Miley Cyrus|Madonna|Zara Larsson|BTS|Labrinth|Pink Sweat$|Stromae|MØ|Mark Ronson|Nico & Vinz|LunchMoney Lewis|Trinidad Cardona|Sub Urban|Dwayne Bravo|B.I|JONY|347aidan|Beowülf|Sarah Cothran") +add('INDIE', "Angus & Julia Stone|Akeboshi|Rocco|Chella|DJ Shadow") +add('RAP', "CJ|MADAX|B Young") +add('EDM', "Taska Black|HVME|Pogo") +SAD = set("Lauv|Ali Gatie|Lewis Capaldi|James Arthur|Dean Lewis|Alec Benjamin|Powfu|Tai Verdes|BoyWithUke|Chance Peña|JP Saxe|Sam Fischer|gnash|Olivia O'Brien|Jeremy Zucker|Sasha Alex Sloan|Social House|Quinn XCII|blackbear|Trevor Daniel|Kina|d4vd|Jake Etheridge|Ayelle|Sara Kays|Rendell Stovall|Mackenzy Mackay|Matt Maltese|Calum Scott|Ruth B.|Christina Perri|Bazzi|Alex Warren|Teddy Swims|Benson Boone|Pink Sweat$|JVKE|Billie Eilish|Olivia Rodrigo|Gracie Abrams|Duncan Laurence|Conrad Sewell|Stanaj|James Bay|Labrinth|Sarah Cothran|Grace VanderWaal|Sad Pilot|nalin|Marlon Funaki|Covered Up|Cartoon|Daniel Levi|salem ilese|Lewis Capaldi|Shawn Mendes|Julia Michaels|Tom Walker|Lukas Graham|Ed Sheeran".split('|')) +HYPE = set("Travis Scott|Future|Lil Uzi Vert|Playboi Carti|Metro Boomin|Young Thug|21 Savage|Gunna|Pop Smoke|DaBaby|Quavo|Migos|Offset|Lil Baby|Yeat|Rich Amiri|¥$|Kanye West|Don Toliver|Lil Yachty|Roddy Ricch|Kodak Black|Gucci Mane|Rick Ross|Meek Mill|Lil Durk|G Herbo|BigXthaPlug|Cardi B|Nicki Minaj|Megan Thee Stallion|Central Cee|Dave|Skepta|Stormzy|Octavian|D-Block Europe|6ix9ine|Tyga|Lil Pump|Blueface|Travis Scott|French Montana|DJ Khaled|Rich The Kid|Baby Keem|Kendrick Lamar|Lefty Gunplay|Yung Fazo|SoFaygo|Lucki|Takeoff|LRN Slime|$uicideboy$|Lil Keed|Sleepy Hallow|Pusha T|Swae Lee|Desiigner|Doechii|Jack Harlow".split('|')) +FEELS = set("Juice WRLD|Mac Miller|NF|XXXTENTACION|Post Malone|Lil Tjay|Polo G|Kid Cudi|Logic|The Kid LAROI|iann dior|Internet Money|Trippie Redd|Lil Tecca|24kGoldn|Tory Lanez|J. Cole|Russ|YNW Melly|NAV|Lil Mosey|PnB Rock|A Boogie Wit da Hoodie|Rod Wave|KYLE|Chance the Rapper|Childish Gambino|Jaden|Hotel Ugly|Connor Price|Atom Swag|Big Sean|Drake|Yung Lean|Ty Dolla $ign|Dave B.|Travis Thompson|Blxst|StaySolidRocky|Rob $tone|Lil Cobaine|WizTheMc|Tommy Richman|Bryce Vine|Mario Judah|Masked Wolf".split('|')) diff --git a/sorter/tools/genres2.py b/sorter/tools/genres2.py new file mode 100644 index 0000000..7111dec --- /dev/null +++ b/sorter/tools/genres2.py @@ -0,0 +1,36 @@ +from genres import G, add, SAD, HYPE, FEELS +add('ARABIC', "Saint Levant|DYSTINCT|Nancy Ajram|Amr Diab|Bryan Mg|Marwan Moussa|Ali Jassim|Khaled Almuthafar|Ayed|Ragheb Alama|Humood AlKhudher|Fadel Chaker|Nimco Happy|Mustafa Al-Abdullah|Abu|Youssra|Ziad Bourji|Babylone|Faia Younan|Warda|Zeina|Chawki|Elyanna|Omar Hisham|Mesut Kurtis|Hamza Namira|Nayer") +add('AFRO', "Olamide|Ruger|Joeboy|Adekunle Gold|Tiwa Savage|Oxlade|Lojay|Wande Coal|DJ Tunez|Amaarae|Maleek Berry|WurlD|StarBoy|FAVE|Libianca|Alikiba|Harmonize|Crayon|R2Bees|Zinoleesky|Spyro|ODUMODUBLVCK|Shallipopi|Ycee|Ceeka RSA|Bruce Melodie|Ajebo Hustlers|Stonebwoy|Runtown|Musa Keys|Nadia Mukami|Afro B|Rvssian|L.A.X|Mavins|LADIPOE|Magixx|Boy Spyce|JayO|Lasmid|Joey B|Shatta Wale|Bad Boy Timz|Naira Marley|Kuami Eugene|Don Jazzy|The Cavemen.|CIZA|JAZZWRLD|Thukuthela|Sabi|MAGIC SYSTEM|Babyboy AV|Tempoe|1da Banton|Victony|Kizz Daniel|Ayra Starr|Rema|Davido|Wizkid|Burna Boy|Tems|CKay|Asake|Fireboy DML|Omah Lay|OMAH LAY|Tyla|Mr Eazi|Sarz|Yemi Alade|Nandy|Arrow Bwoy|Dogo Janja|Exray|Kemuel|Krizbeatz|Magnom|JZyNo|Gabriela Bee|Eugy Official|Midas the Jagaban|DRB Lasgidi|Teezee|Fresh L.|Flash|Dj Lyta|Magix Enga|bigBen|Bayanni|Echelon Beatz|Kristoff|Nora Fatehi|Ahmed Spins|Stevo Atambire|Victor Thompson|Ehis 'D' Greatest|Neji|Sofiya Nzau|Tayc|GIMS|Aya Nakamura|Prinz|Spotless|Terri|S1mba|Lucenzo|Don Omar|Afro B|AMARIA BB|Ceeza Milli|DTG|Mixtape Madness|JAE5|Yarden|EO|Koomz|Strandz|Zerb|Libianca|Kouz1|Ray Dalton") +add('UK', "Dave|Central Cee|AJ Tracey|Stormzy|T MULLA|Hardy Caprio|JAY1|One Acen|Aitch|Tion Wayne|ArrDee|Ramz|MIST|NSG|Knucks|MoStack|Krg The Don|Skepta|J Hus|Octavian|D-Block Europe|Yxng Bane|Kojo Funds|DigDat|Big Shaq|Chunkz|Sheff G|Not3s|Fredo|Cadet|Deno|WSTRN|Russ Millions|Swarmz|Sav'o|A1 x J1|ARTAN|Miraa May|Paigey Cakey|Geko|J Capri|Yungen|Ricky Rich|Dardan|LD|Tiakola|Yungen|Darkoo|Stefflon Don|Wes Nelson|Lotto Boyzz|Nafe Smallz|Headie One|Digga D|Unknown T|K-Trap") +add('AFROHOUSE', "Ginton|Trinix Remix|Trinix|Koba Brown|Craig Isto|June Freedom|Naïka|¿Téo?|Cloud Chief|Onset Music Group|Cupidon|Milaa|Shaqdi|Kish|shy ink|Jaybeecombz|Kaylan Arnold|Luna Mae|Masra|Randell Heye|ChopLife SoundSystem|Elji Beatzkilla|Ralph Beaubrun|Mari Froes|French Fuse|Zandimaz|Osé|Ozedikus|CupidSZN|BoiGizmo|Ned Gee|Colorit|Odeal|brazy|Yvette London|Baydu Adams|Oumou Sangaré|Palane|Richard Bona|Salif Keita|Jorge Aguilar Y Sus Embelecos|Oriana Melissa|Totó La Momposina|Mckay|Amazone|Full Crate|Gaidaa|Michaël Brun|SOLOMON|Naomi Sharon|Keinemusik|Adam Port|Malachiii|Kabusa Oriental Choir|David Walters|Kolinga|Henrik|CORBAL|GoldFord|L.Dre|Tazzy|Eduardo XD|Shea|ZionRay|Big Z|Stellar|Kikimoteleba|Vegedream|Marwa Loud|Lilmaina|Fancy Fingers|Harry Craze|Mohombi|Booboo'zzz All Stars|Sauti Sol|Master KG|Nomcebo Zikode|Shimza|TitoM|Yuppe|S.N.E|Lofi Afrobeats|NDOVU KUU|Fathermoh|Johnny Johnny|Nyashinski|Tambo|Kayymulla|Theo Somolu|JMANI|GIVANO|Kobi Jonz|Xtofa|Maesu|Playyard|Yanchan Produced|Anjulie|Eva Ruiz|Tash|Solven|Kennedy Ryon|Jaden Smith") +add('JAZZ', "Re:um|Emmaline|Raviel Blackthorn|Hajaj|My Boy Arlo|Elowine|Jess Tailor|Éva Valérie|Moon Soul|Ladies and Gentlemen|Anthony Lazaro|Jazzwaves|Laufey|Frank Sinatra|Ella Fitzgerald|Louis Armstrong|Michael Bublé|Nat King Cole|Peggy Lee|Julie London|Count Basie|Nina Simone|Etta James|Dennis van Aarssen|BADBADNOTGOOD|Cookin' On 3 Burners|Postmodern Jukebox|Perry Como|Antônio Carlos Jobim|Isabel van Gelder|Sheléa|Grover Washington|Jr.|The Ink Spots|Charles Trenet|Olivia Dean") +add('PUNK', "Fall Out Boy|All Time Low|Sum 41|blink-182|Avril Lavigne|Lustra|Bowling For Soup|The All-American Rejects|Metro Station|Good Charlotte|Scouting For Girls|Panic! At The Disco|Brendon Urie|Paramore|Green Day|The Offspring|Simple Plan|Neon Trees|Cobra Starship|Gym Class Heroes|Smash Mouth|Fools Garden|Toploader|The Killers|American Authors|The Script|Two Door Cinema Club|FLO|YUNGBLUD|Hayley Williams|Travie McCoy") +add('CARIB', "Chronixx|UB40|Original Koffee|Protoje|Inner Circle|Gyptian|Sister Nancy|Damian Marley|Collie Buddz|Vybz Kartel|Tommy Lee Sparta|Busy Signal|Shane O|Spice|Josey Wales|Mr. Vegas|Barrington Levy|U-Roy|Beenie Man|T.O.K|Cocoa Tea|Marcia Griffiths|Singing Melody|Tony Curtis|Ce'Cile|Christopher Martin|Leroy Sibbles|Freddie McGregor|J Boog|Big Mountain|Rik Rok|Brian & Tony Gold|Musical Youth|Skeng|1stClass|1Biggs Don|Steel Chest|Wyclef Jean|Fugees|Ms. Lauryn Hill|Gente De Zona|Descemer Bueno|Iration|Rvssian|Bob Marley & The Wailers") +add('CLASSIC', "Elton John|Johnny Cash|The Beach Boys|Stevie Wonder|George Harrison|AC/DC|The Vogues|Glen Campbell|Van Morrison|Creedence Clearwater Revival|Kenny Rogers|Gotye|Kimbra|The Police|Lynyrd Skynyrd|TOTO|Paul Simon|The Outfield|Commodores|Buffalo Springfield|The Verve|Creed|Chuck Berry|Crowded House|Naked Eyes|Aerosmith|Black Sabbath|Kansas|The Righteous Brothers|Rupert Holmes|Bob Dylan|The Turtles|Marty Robbins|The Temptations|Redbone|Tammi Terrell|Looking Glass|Dire Straits|Neil Diamond|The Foundations|Matthew Wilder|Nile Rodgers|Carl Douglas|The Cardigans|Jerry Lee Lewis|The Ronettes|The Mamas & The Papas|Bonnie Tyler|Bon Jovi|Brenton Wood|Don McLean|Men At Work|The Rolling Stones|Tom Jones|a-ha|Nancy Sinatra|Katrina & The Waves|Oasis|Guns N' Roses|Boston|Haddaway|Bryan Adams|Eagles|B.J. Thomas|Van Halen|Simon & Garfunkel|Tears For Fears|Electric Light Orchestra|Kiki Dee|Simple Minds|Counting Crows|The Monkees|Culture Club|Ben E. King|U2|David Bowie|Prince|Radiohead|Billy Ocean|The Smiths|The Animals|Franz Ferdinand|The Clash|Frankie Valli|Al Green|Lionel Richie|The Jacksons|Silver|Daryl Hall & John Oates|Mariah Carey|Britney Spears|Gnarls Barkley|Ashanti|Keri Hilson|Mario|Omarion|Trey Songz|Jay Sean|T-Pain|Flo Rida") +G.update({'T-Pain':'POP','Flo Rida':'POP','Jay Sean':'POP','Britney Spears':'POP','Mariah Carey':'RNB','Ashanti':'RNB','Keri Hilson':'RNB','Mario':'RNB','Omarion':'RNB','Trey Songz':'RNB','Gnarls Barkley':'POP'}) +add('COUNTRY', "Chris Stapleton|Mumford & Sons|Dylan Gossett|The Steeldrivers|Gregory Alan Isakov|Zac Brown Band|The Red Clay Strays|Sam Hunt|Kenny Chesney|Hollow Coves|Matt Maeson|Watchhouse|The Paper Kites|Josh Turner|Edward Sharpe & The Magnetic Zeros|Kacey Musgraves|Mt. Joy|Dolly Parton|Amos Lee|Donavon Frankenreiter|Wesley Schultz|Stephen Sanchez|Zach Bryan|Tyler Childers|Morgan Wallen|Luke Combs|Michael Marcagi|Noah Kahan|Hozier|Myles Smith|George Ezra|Alex Warren|Vance Joy") +add('EDM', "Naeleck|Daft Punk|Swedish House Mafia|John Martin|PNAU|Joel Corry|Dimitri Vegas|Marnik|Gabry Ponte|Regard|Rudimental|Porter Robinson|Madeon|Flume|Showtek|Jack Ü|Skrillex|felix jaehn|Matt Sassari|Steve Aoki|Ann Lee|Sound Of Legend|Timmy Trumpet|POLTERGST|Dimitri Vegas & Like Mike|W&W|Like Mike|VINAI|DJ Fluke|BYOR|Fred again..|James Hype|Miggy Dela Rosa|Peggy Gou|ACRAZE|Vini Vici|Creeds|INNA|Sash!|A Touch Of Class|Chase & Status|Prezioso|Coopex|Deorro|Vicetone|Tony Igy|Nightcrawlers|Mufasa & Hypeman|Mufasa|Capital Cities|Disciples|Lido|Cherish|LUM!X|Majestic|Stryv|Orso|Kim Petras|Sevdaliza|Pabllo Vittar|Yseult|Artemas|Bou|Flowdan|IRAH|Trigga") +add('RNB', "Majid Jordan|Miguel|Sonder|Jorja Smith|MNEK|Masego|Tiffany Gouché|Rayana Jay|Zacari|Pip Millett|Dwele|Syleena Johnson|Anderson .Paak|Silk Sonic|Coco Jones|Nao|Mustafa|keshi|Bipolar Sunshine|Snoh Aalegra|Ria Sean|TANISHA|Honey Mooncie|Lucy Park|spring gang|Joe Hertz|Toni Kawaii|Hannymoon|Mitchell Yard|ZYHH|((( O ))) |Astyn Turr|Mirella Costa|Sam Wills") +add('LATIN', "Anitta|Projota|Claudia Leitte|Don Omar") +add('RAP', "The Game|A$AP Ferg|Sheck Wes|Outlawz|Kid Ink|Rae Sremmurd|Yung Joc|JACKBOYS|Nappy Roots|Tay Keith|Lyrical Lemonade|Khalil Harrison|Tyler ICU|LeeMcKrazy|Paperboy Fabe|sosocamo") +G['Don Omar']='LATIN' +for a in "Queen|Michael Jackson|ABBA|The Jackson 5|Nirvana|Red Hot Chili Peppers|Journey|Survivor|Foreigner".split('|'): G[a]='CLASSIC' +G['Akon']='POP' +add('INDIE',"Not the King|Takayoshi|Vansire|Liana Flores|Mitski|Coyote Theory|Seizo|Peachy!|mxmtoon|Lyn Lapid|Sushisingz|Mild High Club|Zeph|girl in red|The Neighbourhood|PREP") +add('CLASSIC',"Trinitones|Street Corner Renaissance|The Daughters Of Eve|Keith Green") +add('POP',"Silentó") +add('UK',"Sainté") +add('AFRO',"Nathaniel Shalom") +add('SKIP',"Postman Pat|Bryan Daly|Ken Barrie|Colm R. McGuinness") +# placements decided by hand for the Nov 2026 update +add('JAZZ', "Stan Getz|Isabel van Gelder") +add('INDIE', "wave to earth|yung kai|HYBS|Mazzy Star|Weezer") +add('COUNTRY', "Cowboy Malfoy") +add('RAP', "Flo Milli") +add('AFRO', "Chisway") +G['The Script'] = 'ANTHEM' +G['Travie McCoy'] = 'POP' +FEELS.add('Flo Milli') +# names that contain a comma (Exportify escapes them as '\\,'; classify.artists() unescapes) +G.update({'Tyler, The Creator': 'RAP', 'Earth, Wind & Fire': 'CLASSIC', 'Grover Washington, Jr.': 'JAZZ'}) +FEELS.add('Tyler, The Creator') diff --git a/sorter/tools/get_fonts.sh b/sorter/tools/get_fonts.sh new file mode 100644 index 0000000..570d6c8 --- /dev/null +++ b/sorter/tools/get_fonts.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Downloads the Google Fonts (OFL) used by tools/covers.py into ./fonts +set -e; mkdir -p fonts; cd fonts +B=https://raw.githubusercontent.com/google/fonts/main +for f in ofl/bebasneue/BebasNeue-Regular.ttf ofl/anton/Anton-Regular.ttf ofl/pacifico/Pacifico-Regular.ttf \ + "ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf" "ofl/playfairdisplay/PlayfairDisplay-Italic%5Bwght%5D.ttf" \ + ofl/monoton/Monoton-Regular.ttf ofl/rubikmonoone/RubikMonoOne-Regular.ttf ofl/archivoblack/ArchivoBlack-Regular.ttf \ + "ofl/caveat/Caveat%5Bwght%5D.ttf" ofl/dmserifdisplay/DMSerifDisplay-Regular.ttf ofl/dmserifdisplay/DMSerifDisplay-Italic.ttf \ + ofl/rye/Rye-Regular.ttf ofl/yatraone/YatraOne-Regular.ttf ofl/vt323/VT323-Regular.ttf ofl/italiana/Italiana-Regular.ttf \ + "ofl/unbounded/Unbounded%5Bwght%5D.ttf" ofl/shrikhand/Shrikhand-Regular.ttf ofl/abrilfatface/AbrilFatface-Regular.ttf \ + ofl/bungee/Bungee-Regular.ttf ofl/lobster/Lobster-Regular.ttf ofl/righteous/Righteous-Regular.ttf \ + ofl/bowlbyonesc/BowlbyOneSC-Regular.ttf ofl/instrumentserif/InstrumentSerif-Italic.ttf; do + n=$(basename "$f" | sed 's/%5B.*%5D//'); curl -sfL "$B/$f" -o "$n" && echo "ok $n" +done diff --git a/sorter/tools/update.py b/sorter/tools/update.py new file mode 100644 index 0000000..11ed844 --- /dev/null +++ b/sorter/tools/update.py @@ -0,0 +1,129 @@ +"""Sort newly liked songs into the existing playlists. + +Usage: + python tools/update.py liked.csv # dry run: show where each new song would go + python tools/update.py liked.csv --apply # write data/playlists.json and rebuild site/index.html +Options: + --since 2026-11-08T00:00:00Z only look at songs liked at or after this time (default: data/state.json) + --place TRACK_ID="playlist" put one song in a playlist by hand (repeatable; TRACK_ID or full Spotify link) + --skip TRACK_ID leave one song out (repeatable) + --keep-fresh keep last update's "fresh" flags (use if those songs aren't pasted yet) + +liked.csv is an Exportify export of Liked Songs (English column headers). A song is "new" when it was liked +since the last update and isn't already in a playlist (any version of it). Each new song goes to: + 1. a --place override, else + 2. a hand-curated playlist the genre rules never produce (french vibes, café parisien, lofi study), when it + holds most of the primary artist's songs, else + 3. the classifier's playlist (tools/classify.py), if the primary artist already has songs there, else + 4. the playlist that already holds most of the primary artist's songs (this respects hand placements, + e.g. French artists in "french vibes"), else + 5. the classifier's playlist, or "??" when the artist is unknown. --apply refuses to run while any "??" remain. +""" +import argparse, collections, csv, json, os, re, subprocess, sys +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from classify import KEY_TO_PLAYLIST, artists, genre, sub + +R = os.path.join(HERE, '..') +PLAYLISTS = os.path.join(R, 'data/playlists.json') +STATE = os.path.join(R, 'data/state.json') +DEFAULT_SINCE = '2026-11-08T00:00:00Z' # the Nov 8 2026 update; songs from that day already placed are skipped by id + + +def track_id(s): + return re.sub(r'.*[/:]', '', s.split('?')[0].strip()) + + +def song_key(name, artist_field): + return (re.sub(r'\s*[-(\[].*', '', name).lower().strip(), (artists(artist_field) or [''])[0]) + + +def load_json(p, default): + return json.load(open(p, encoding='utf-8')) if os.path.exists(p) else default + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('csv') + ap.add_argument('--apply', action='store_true') + ap.add_argument('--since') + ap.add_argument('--place', action='append', default=[]) + ap.add_argument('--skip', action='append', default=[]) + ap.add_argument('--keep-fresh', action='store_true') + a = ap.parse_args() + + data = json.load(open(PLAYLISTS, encoding='utf-8')) + names = [p['name'] for p in data] + state = load_json(STATE, {}) + since = a.since or state.get('last_update', DEFAULT_SINCE) + place = {} + for x in a.place: + tid, _, pl = x.rpartition('=') + if pl not in names: sys.exit(f'--place: no playlist named {pl!r}. Playlists: {", ".join(names)}') + place[track_id(tid)] = pl + skip = {track_id(x) for x in a.skip} + + have_ids = {t[0] for p in data for t in p['tracks']} + have_keys = {song_key(t[1], t[2]): p['name'] for p in data for t in p['tracks']} + curated = set(names) - {v for v in KEY_TO_PLAYLIST.values() if v} + history = collections.defaultdict(collections.Counter) # primary artist -> playlist counts (picks count too) + for p in data: + for t in p['tracks']: history[(artists(t[2]) or [''])[0]][p['name']] += 1 + + rows = list(csv.DictReader(open(a.csv, encoding='utf-8-sig'))) + need = {'Track URI', 'Track Name', 'Artist Name(s)', 'Added At'} + if rows and not need <= rows[0].keys(): + sys.exit(f'{a.csv} is missing columns {sorted(need - rows[0].keys())}. Export with Exportify in English.') + latest = max([r['Added At'] for r in rows] + [since]) + rows = sorted((r for r in rows if r['Added At'] >= since), key=lambda r: r['Added At'], reverse=True) + + plan = collections.defaultdict(list); notes = [] + for r in rows: + tid = track_id(r['Track URI']) + if not tid or r['Track URI'].startswith('spotify:local:') or tid in have_ids: continue + label = f"{r['Track Name']} | {r['Artist Name(s)']}" + if tid in skip: notes.append(f'skipped by hand: {label}'); continue + k = song_key(r['Track Name'], r['Artist Name(s)']) + if k in have_keys and tid not in place: + notes.append(f'already have a version in "{have_keys[k]}": {label}'); continue + main_artist = (artists(r['Artist Name(s)']) or [''])[0] + rule = KEY_TO_PLAYLIST.get(sub(r, genre(r))) + seen = history.get(main_artist) + if tid in place: pl, why = place[tid], 'placed by hand' + elif rule is None: notes.append(f'skip-listed artist: {label}'); continue + elif seen and seen.most_common(1)[0][0] in curated: pl, why = seen.most_common(1)[0][0], 'hand-curated artist' + elif seen and rule in seen: pl, why = rule, 'genre rule' + elif seen: pl, why = seen.most_common(1)[0][0], f'artist already in "{seen.most_common(1)[0][0]}"' + elif rule in names: pl, why = rule, 'genre rule (artist new to playlists)' + else: + pl, why = '??', 'unknown artist' + if r.get('Artist Genres'): why += f" (Spotify genres: {r['Artist Genres']})" + have_keys[k] = pl + plan[pl].append((tid, r, why)) + + total = sum(len(v) for v in plan.values()) + print(f'Songs liked since {since}: {len(rows)}. New to sort: {total}.') + for pl in sorted(plan, key=lambda x: (x != '??', x)): + print(f'\n== {pl} ({len(plan[pl])})') + for tid, r, why in plan[pl]: + print(f" {r['Track Name']} | {r['Artist Name(s)']} | {tid} | {why}") + if notes: print('\nNot added:'); [print(' ' + n) for n in notes] + + if not a.apply: + print('\nDry run. Re-run with --apply to write the playlists.'); return + if '??' in plan: + sys.exit('\nPlace the ?? songs first: add the artist to tools/genres2.py, or pass --place ID="playlist" / --skip ID.') + if not a.keep_fresh: + for p in data: p['tracks'] = [[f for f in t if f != 'fresh'] for t in p['tracks']] + by_name = {p['name']: p for p in data} + for pl, items in plan.items(): + by_name[pl]['tracks'][:0] = [[tid, r['Track Name'], r['Artist Name(s)'], 'fresh'] for tid, r, _ in items] + json.dump(data, open(PLAYLISTS, 'w', encoding='utf-8'), ensure_ascii=False, separators=(',', ':')) + state['last_update'] = latest + json.dump(state, open(STATE, 'w', encoding='utf-8'), indent=1) + print(f'\nAdded {total} songs. Next update looks at songs liked from {latest}.') + subprocess.run([sys.executable, os.path.join(HERE, 'build_page.py')], check=True) + + +if __name__ == '__main__': + main()