spot init
This commit is contained in:
1 parent
9ccc3cea95
commit
96d0331ff3
3 files changed
+177
-2
No files matched your search
@@ -28,6 +28,10 @@ dl trg:
|
|||||||
link plist dir:
|
link plist dir:
|
||||||
python3 maple-py/link.py {{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:
|
sync args:
|
||||||
s5cmd {{args}}
|
s5cmd {{args}}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ maple = "maple.cli:main"
|
|||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.uv]
|
[dependency-groups]
|
||||||
dev-dependencies = [
|
dev = [
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
]
|
]
|
||||||
@@ -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 "<url-or-id> ..." # explicit playlists
|
||||||
|
just spotify "--file lists.txt --json"
|
||||||
|
just spotify "<url-or-id> --csv" # single sockseek CSV (stdout)
|
||||||
|
just spotify "<url-or-id> --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 `# <name>` comment line as a separator. Writes to stdout, or to a file
|
||||||
|
with --out <path>.
|
||||||
|
"""
|
||||||
|
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 `# <name>` 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] <url-or-id ...> "
|
||||||
|
"[--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()
|
||||||
Reference in new issue
Block a user