效率工具

Spotify:把一张 CSV 表(歌名、歌手)变成你自己账号下的私密歌单

把一张 CSV 表(歌名、歌手)变成你自己账号下的私密歌单:Python 脚本用 PKCE 方式登录,逐首搜索、用歌名和歌手过滤掉翻唱版、去掉重复,然后建歌单并批量加歌,最后列出没找到的歌。代码按 2026 年 2 月开发模式的新规则编写。

BINBHO · 图文教程

把一张 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 步:真实运行(本机未执行)

  1. 在 Dashboard → 应用 → Settings → User Management 里,把要使用的账号加进名单(包括你自己)。
  2. 运行:
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 时按服务器给的等待时间重试。

项目资源

用途 地址
PKCE 授权流程 https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
Redirect URI 规则 https://developer.spotify.com/documentation/web-api/concepts/redirect_uri
Search 参考 https://developer.spotify.com/documentation/web-api/reference/search
Create Playlist https://developer.spotify.com/documentation/web-api/reference/create-playlist
Add Items to Playlist https://developer.spotify.com/documentation/web-api/reference/add-items-to-playlist
RFC 7636(PKCE) https://www.rfc-editor.org/rfc/rfc7636
2026 年 2 月开发模式变更迁移指南 https://developer.spotify.com/documentation/web-api/tutorials/february-2026-migration-guide
配额模式 https://developer.spotify.com/documentation/web-api/concepts/quota-modes
开发者条款 https://developer.spotify.com/terms

Spotify 的接口这两年改了不少,照着旧教程写,最容易出错的就是 limit、建歌单和歌单曲目这三个地方。用假服务把新规则写进测试,以后接口再改也能第一时间发现。