把一张 CSV 表(歌名、歌手)变成你自己账号下的私密歌单:Python 脚本用 PKCE 方式登录,逐首搜索、用歌名和歌手过滤掉翻唱版、去掉重复,然后建歌单并批量加歌,最后列出没找到的歌。代码按 2026 年 2 月开发模式的新规则编写。
先看清成品
本机用一个本地假 Spotify 服务跑了 2 个测试,全部通过:PKCE challenge 与 RFC 7636 附录 B 的官方测试向量一致;第一次搜索返回 429,脚本按
Retry-After重试;翻唱版被过滤、重复的歌只加一次;建歌单时public: false。用假 token 请求真实的/v1/search,返回401 Missing/invalid/expired access token。没有用真实账号登录和建歌单(需要有 Premium 的开发者账号)。
注意:Spotify 订阅价格以官方页面为准,本文不列数字。官方开发者文档写明:开发模式(Development Mode)应用的所有者必须有有效的 Spotify Premium,每个应用最多 5 个授权用户,并且只开放部分接口,用于个人和非商业的学习项目。不要下载或录制音频,不要绕过 DRM 或地区限制,不要共享账号;只有你加入名单的用户才能登录你的应用。
先了解:2026 年的接口变化
官方迁移指南(February 2026 Web API Dev Mode Changes)里,对本系列有影响的几条:
| 变化 | 内容 |
|---|---|
| Premium | 开发模式应用的所有者必须有有效的 Premium,过期后应用停止工作 |
| 用户数 | 每个应用最多 5 个用户,需要在后台逐个加入名单 |
| 搜索 | limit 最大值从 50 改为 10,默认值从 20 改为 5 |
| 建歌单 | POST /users/{{user_id}}/playlists 改为 POST /me/playlists |
| 歌单曲目 | /playlists/{{id}}/tracks 改名为 /playlists/{{id}}/items,响应里的 tracks 字段改名为 items |
| 删除的字段 | 曲目的 popularity、用户的 email/country/product 等 |
| 更早(2024-11-27) | 新应用不能再用 Recommendations、Audio Features、Related Artists 等接口 |
网上很多旧教程用的还是上面这些已经废弃的接口,照抄会报错。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
spotify_pkce.py |
本地回调地址 127.0.0.1,校验 state,没有 client secret |
csv_to_playlist.py |
搜索、匹配、去重、建私密歌单、每次最多加 100 首 |
| 限流处理 | 遇到 429 按 Retry-After 等待 |
fake_spotify.py |
离线测试用的假服务 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Spotify 账号 | 应用所有者要有 Premium | 开发模式的要求 |
| 应用 | 在 developer.spotify.com 的 Dashboard 创建 | Redirect URI 填 http://127.0.0.1:8888/callback |
| Python | 3.9 以上(本机 3.13) | pip install -r requirements.txt |
官方的 Redirect URI 规则:必须和后台登记的完全一致;不用 HTTPS 的话只能用回环地址,而且要写明确的 IP(127.0.0.1 或 [::1]),不要写 localhost。
文件结构
31-spotify-A-src/
├── spotify_pkce.py
├── csv_to_playlist.py
├── songs.csv
├── fake_spotify.py
├── test_playlist.py
└── requirements.txt
第 1 步:PKCE 登录
spotify_pkce.py:
"""最小的 PKCE 授权:在 127.0.0.1 上起一个本地回调,拿到 access token。
Spotify 要求回调地址使用回环 IP(127.0.0.1),并且与应用后台登记的地址完全一致。"""
import base64, hashlib, http.server, secrets, threading, urllib.parse, webbrowser
import requests
AUTH = "https://accounts.spotify.com/authorize"
TOKEN = "https://accounts.spotify.com/api/token"
def make_verifier(n=64):
return secrets.token_urlsafe(n)[:n] # 43–128 个字符,取值为 URL 安全字符
def challenge(verifier):
return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
def login(client_id, scopes, port=8888, open_browser=webbrowser.open):
redirect = f"http://127.0.0.1:{port}/callback"
verifier, state = make_verifier(), secrets.token_urlsafe(16)
got = {}
class H(http.server.BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
got.update({k: v[0] for k, v in q.items()})
self.send_response(200); self.send_header("Content-Type", "text/plain; charset=utf-8"); self.end_headers()
self.wfile.write("授权完成,可以关闭这个页面。".encode())
srv = http.server.HTTPServer(("127.0.0.1", port), H)
t = threading.Thread(target=srv.handle_request); t.start()
open_browser(AUTH + "?" + urllib.parse.urlencode({
"client_id": client_id, "response_type": "code", "redirect_uri": redirect,
"code_challenge_method": "S256", "code_challenge": challenge(verifier),
"state": state, "scope": " ".join(scopes)}))
t.join(timeout=300); srv.server_close()
if got.get("state") != state: raise RuntimeError("state 不匹配,可能是伪造的回调")
if "error" in got: raise RuntimeError("用户拒绝或授权失败:" + got["error"])
r = requests.post(TOKEN, data={"grant_type": "authorization_code", "code": got["code"], "redirect_uri": redirect,
"client_id": client_id, "code_verifier": verifier}, timeout=15)
r.raise_for_status()
return r.json()["access_token"]
PKCE 适合不能安全保存 client secret 的程序:随机生成 verifier,对它做 SHA-256,再用 base64url 编码得到 challenge,授权时发 challenge,换 token 时发 verifier。
第 2 步:建歌单
csv_to_playlist.py:
"""把 CSV(title,artist)里的歌曲建成一个私密歌单。只用 2026 年 2 月后开发模式仍可用的接口:
GET /search(limit 最大 10)、POST /me/playlists、POST /playlists/{id}/items(每次最多 100 条)。
用法:SPOTIFY_CLIENT_ID=... python csv_to_playlist.py songs.csv "歌单名" """
import csv, os, sys, time
import requests
API = "https://api.spotify.com/v1"
SCOPES = ["playlist-modify-private"]
class Spotify:
def __init__(self, token, api=API, session=None):
self.api, self.s = api, session or requests.Session()
self.s.headers["Authorization"] = f"Bearer {token}"
def call(self, method, path, **kw):
for attempt in range(5):
r = self.s.request(method, self.api + path, timeout=15, **kw)
if r.status_code == 429: # 限流:按 Retry-After 等待
time.sleep(int(r.headers.get("Retry-After", "1"))); continue
r.raise_for_status()
return r.json() if r.content else {}
raise RuntimeError("多次被限流,稍后再试")
def norm(s): return "".join(ch for ch in s.lower() if ch.isalnum())
def find_track(sp, title, artist):
q = f'track:"{title}" artist:"{artist}"'
items = sp.call("GET", "/search", params={"q": q, "type": "track", "limit": 5})["tracks"]["items"]
for t in items: # 优先选歌名和歌手都对得上的结果
if norm(t["name"]).startswith(norm(title)) and any(norm(a["name"]) == norm(artist) for a in t["artists"]):
return t
return None
def run(sp, rows, name):
found, missing = [], []
for row in rows:
t = find_track(sp, row["title"], row["artist"])
(found.append(t["uri"]) if t else missing.append(f'{row["title"]} - {row["artist"]}'))
found = list(dict.fromkeys(found)) # 去重并保持顺序
pl = sp.call("POST", "/me/playlists", json={"name": name, "public": False, "description": "由 CSV 导入"})
for i in range(0, len(found), 100):
sp.call("POST", f"/playlists/{pl['id']}/items", json={"uris": found[i:i + 100]})
return pl, found, missing
if __name__ == "__main__":
from spotify_pkce import login
rows = list(csv.DictReader(open(sys.argv[1], encoding="utf-8-sig")))
sp = Spotify(login(os.environ["SPOTIFY_CLIENT_ID"], SCOPES))
pl, found, missing = run(sp, rows, sys.argv[2])
print(f"歌单:{pl['external_urls']['spotify']} 加入 {len(found)} 首")
for m in missing: print("未找到:", m)
songs.csv(故意包含一首不存在的歌和一首重复的歌):
title,artist
晴天,周杰伦
Shape of You,Ed Sheeran
不存在的歌,某某
晴天,周杰伦
要点:
- 搜索用
track:"…" artist:"…"字段语法,limit=5(新规则下最大是 10)。 - 不直接用第一条搜索结果:搜“晴天”时排在前面的可能是翻唱,所以要求歌手名也一致。
- 只申请
playlist-modify-private这一个 scope。
第 3 步:离线测试
fake_spotify.py:
"""本地假 Spotify Web API:search / me/playlists / playlists/{id}/items,第一次 search 返回 429。"""
import json, threading, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
CATALOG = [
{"uri": "spotify:track:aaa", "name": "晴天", "artists": [{"name": "周杰伦"}]},
{"uri": "spotify:track:k1", "name": "晴天 (Live)", "artists": [{"name": "某翻唱"}]},
{"uri": "spotify:track:bbb", "name": "Shape of You", "artists": [{"name": "Ed Sheeran"}]},
]
def make(state):
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def send(self, code, obj=None, headers=()):
b = json.dumps(obj).encode() if obj is not None else b""
self.send_response(code)
for k, v in headers: self.send_header(k, v)
self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
def do_GET(self):
assert self.headers["Authorization"] == "Bearer test-token"
u = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(u.query)
state["search"] += 1
if state["search"] == 1: return self.send(429, {"error": {"status": 429}}, [("Retry-After", "0")])
assert int(q["limit"][0]) <= 10
title = q["q"][0].split('"')[1]
items = [t for t in CATALOG if title.lower() in t["name"].lower()]
self.send(200, {"tracks": {"items": items}})
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if self.path == "/me/playlists":
state["playlist"] = body
return self.send(201, {"id": "pl1", "external_urls": {"spotify": "https://open.spotify.com/playlist/pl1"}})
state["added"].append(body["uris"]); self.send(201, {"snapshot_id": "s1"})
srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv
test_playlist.py:
import csv
from fake_spotify import make
from csv_to_playlist import Spotify, run
from spotify_pkce import challenge, make_verifier
def test_rfc7636_vector(): # RFC 7636 附录 B 的官方测试向量
assert challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
v = make_verifier(); assert 43 <= len(v) <= 128
def test_run_against_fake():
st = {"search": 0, "added": []}; srv = make(st)
sp = Spotify("test-token", api=f"http://127.0.0.1:{srv.server_address[1]}")
rows = list(csv.DictReader(open("songs.csv", encoding="utf-8-sig")))
pl, found, missing = run(sp, rows, "测试歌单")
assert found == ["spotify:track:aaa", "spotify:track:bbb"] # 翻唱版被过滤,重复歌曲去重
assert missing == ["不存在的歌 - 某某"]
assert st["playlist"]["public"] is False and st["added"] == [found]
srv.shutdown()
pip install -r requirements.txt
python -m pytest -q # 2 passed
第 4 步:真实运行(本机未执行)
- 在 Dashboard → 应用 → Settings → User Management 里,把要使用的账号加进名单(包括你自己)。
- 运行:
export SPOTIFY_CLIENT_ID=你的ClientID
python csv_to_playlist.py songs.csv "CSV 导入测试"
浏览器会打开授权页,同意后终端输出歌单链接,再列出 未找到:不存在的歌 - 某某。
调试示例
用旧教程的写法,搜索参数 limit=20
旧教程大多写 limit=20 或 limit=50。按迁移指南,开发模式下 limit 最大只能是 10。本机假服务里加了 assert limit <= 10,就是为了防止回退到旧写法。另外,本机用假 token 请求真实接口得到:
{"error": {"status": 401, "message": "Missing/invalid/expired access token"}}
这个错误发生在参数校验之前,所以不能拿它来判断参数是否合法。参数是否合规,以官方参考文档为准(search 页面写的是 Range: 0 - 10)。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
INVALID_CLIENT: Invalid redirect URI |
回调地址和后台登记的不一致,或者写成了 localhost | 两边都写 http://127.0.0.1:8888/callback |
| 403 | 登录的账号不在应用的用户名单里,或者所有者的 Premium 过期了 | 在 User Management 里添加账号;检查订阅 |
404 /users/…/playlists |
用了已经删除的旧接口 | 改用 POST /me/playlists |
| 很多歌找不到 | 歌名写法不同(全角/半角、带副标题) | 改 CSV,或放宽 find_track 的匹配规则 |
模板规则
- 只用当前开发模式支持的接口,参考 2026 年 2 月的迁移指南。
- 歌单默认设为私密,只申请需要的最小 scope。
- 搜索结果要用歌手名再核对一遍。
- 遇到 429 时按服务器给的等待时间重试。
项目资源
Spotify 的接口这两年改了不少,照着旧教程写,最容易出错的就是 limit、建歌单和歌单曲目这三个地方。用假服务把新规则写进测试,以后接口再改也能第一时间发现。