From 96d0331ff3e6ae44c964ca96d9b6dc49ce99340e Mon Sep 17 00:00:00 2001 From: Nathan Rashleigh Date: Sun, 21 Jun 2026 10:16:36 +1000 Subject: [PATCH] spot init --- justfile | 4 + maple-py/pyproject.toml | 4 +- maple-py/src/spotify.py | 171 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 maple-py/src/spotify.py diff --git a/justfile b/justfile index b83e1ac..41a1b2b 100644 --- a/justfile +++ b/justfile @@ -28,6 +28,10 @@ dl trg: link plist dir: python3 maple-py/link.py {{plist}} {{dir}} +# Dump tracks from Spotify playlists (ids, urls, or spotify: uris) +spotify ids="7f5tPIfj21UJI4zfDmH4rx 6sAl8LhU6ediFNJRuQQIQX": + cd maple-py && uv run src/spotify.py {{ids}} --csv + sync args: s5cmd {{args}} diff --git a/maple-py/pyproject.toml b/maple-py/pyproject.toml index 2409fa4..518d331 100644 --- a/maple-py/pyproject.toml +++ b/maple-py/pyproject.toml @@ -21,7 +21,7 @@ maple = "maple.cli:main" requires = ["hatchling"] build-backend = "hatchling.build" -[tool.uv] -dev-dependencies = [ +[dependency-groups] +dev = [ "pytest>=9.0.3", ] diff --git a/maple-py/src/spotify.py b/maple-py/src/spotify.py new file mode 100644 index 0000000..e678f32 --- /dev/null +++ b/maple-py/src/spotify.py @@ -0,0 +1,171 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.14" +# dependencies = [] +# /// +"""Dump tracks from one or more Spotify playlists (Client Credentials flow). + + just spotify # uses the placeholder ids below + just spotify " ..." # explicit playlists + just spotify "--file lists.txt --json" + just spotify " --csv" # single sockseek CSV (stdout) + just spotify " --csv --out tracks.csv" + +Playlists can be ids, full URLs, or spotify: URIs. Works for any public +playlist; no user login needed. Requires SPOTIFY_CLIENT_ID / +SPOTIFY_CLIENT_SECRET in the environment (loaded from .env by just). + +--csv writes one combined CSV with Artist,Title,Album,Length columns (Length +in seconds), the format consumed by sockseek/sldl. Each playlist is preceded +by a `# ` comment line as a separator. Writes to stdout, or to a file +with --out . +""" +import base64 +import csv +import json +import os +import re +import sys +import urllib.parse +import urllib.request + + +def get_token(client_id, client_secret): + auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() + data = urllib.parse.urlencode({"grant_type": "client_credentials"}).encode() + req = urllib.request.Request( + "https://accounts.spotify.com/api/token", + data=data, + headers={"Authorization": f"Basic {auth}"}, + ) + with urllib.request.urlopen(req) as resp: + return json.load(resp)["access_token"] + + +def api_get(url, token): + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}) + with urllib.request.urlopen(req) as resp: + return json.load(resp) + + +def parse_playlist_id(arg): + m = re.search(r"playlist[/:]([A-Za-z0-9]+)", arg) + return m.group(1) if m else arg.strip() + + +def read_list_file(path): + out = [] + with open(path) as fh: + for line in fh: + line = line.split("#", 1)[0].strip() + if line: + out.append(line) + return out + + +def fetch_playlist(pid, token): + meta = api_get( + f"https://api.spotify.com/v1/playlists/{pid}" + f"?fields={urllib.parse.quote('name,owner(display_name)')}", + token, + ) + fields = "items(track(name,artists(name),album(name),duration_ms)),next" + url = ( + f"https://api.spotify.com/v1/playlists/{pid}/tracks" + f"?limit=100&fields={urllib.parse.quote(fields)}" + ) + tracks = [] + while url: + page = api_get(url, token) + for item in page.get("items", []): + t = item.get("track") + if not t: + continue + tracks.append( + { + "title": t["name"], + "artists": [a["name"] for a in t["artists"]], + "album": t["album"]["name"], + "duration_ms": t["duration_ms"], + } + ) + url = page.get("next") + return {"id": pid, "name": meta.get("name"), "tracks": tracks} + + +def write_csv(playlists, fh): + """Single combined sockseek/sldl CSV, one `# ` comment per playlist.""" + w = csv.writer(fh) + w.writerow(["Artist", "Title", "Album", "Length"]) + for pl in playlists: + fh.write(f"# {pl['name']}\n") + for t in pl["tracks"]: + w.writerow( + [ + ", ".join(t["artists"]), + t["title"], + t["album"], + round(t["duration_ms"] / 1000), + ] + ) + + +def main(): + argv = sys.argv[1:] + as_json = "--json" in argv + as_csv = "--csv" in argv + argv = [a for a in argv if a not in ("--json", "--csv")] + + inputs = [] + out_path = None + i = 0 + while i < len(argv): + if argv[i] == "--file": + inputs += read_list_file(argv[i + 1]) + i += 2 + elif argv[i] == "--out": + out_path = argv[i + 1] + i += 2 + else: + inputs.append(argv[i]) + i += 1 + + if not inputs: + sys.exit( + "usage: spotify.py [--json|--csv] " + "[--file list.txt] [--out path]" + ) + + client_id = os.environ.get("SPOTIFY_CLIENT_ID") + client_secret = os.environ.get("SPOTIFY_CLIENT_SECRET") + if not (client_id and client_secret): + sys.exit("set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET") + + token = get_token(client_id, client_secret) + playlists = [fetch_playlist(parse_playlist_id(x), token) for x in inputs] + + if as_json: + print(json.dumps(playlists, indent=2, ensure_ascii=False)) + return + + if as_csv: + if out_path: + with open(out_path, "w", newline="") as fh: + write_csv(playlists, fh) + print(f"wrote {sum(len(p['tracks']) for p in playlists)} tracks to {out_path}") + else: + write_csv(playlists, sys.stdout) + return + + if as_json: + print(json.dumps(playlists, indent=2, ensure_ascii=False)) + return + + for pl in playlists: + print(f"\n=== {pl['name']} ({len(pl['tracks'])} tracks) ===") + for n, t in enumerate(pl["tracks"], 1): + print(f"{n:>3}. {', '.join(t['artists'])} - {t['title']}") + + +if __name__ == "__main__": + main()