将 Spotify 当前播放接口替换为 Last.fm

之前博客里有一个 spotify.datehoer.com 的小接口,用来给页面展示当前正在听的歌。最开始的实现是后端拿 Spotify refresh token 换 access token,然后请求 Spotify Web API 的 /me/player/currently-playing

这次迁移的原因不是 Nginx、DNS 或服务部署问题,而是 Spotify 上游直接拒绝了请求。

问题现象

后端请求 Spotify token 是成功的:

TOKEN_STATUS 200
TOKEN_SCOPE user-read-currently-playing

但是继续请求播放器接口时,几个相关接口都返回 403

/currently-playing -> 403
/player -> 403
/recently-played -> 403

Spotify 返回的原文是:

Active premium subscription required for the owner of the app.
When the subscription status changes, it can take a few hours before requests are allowed again.

意思是创建这个 Spotify Developer App 的 owner 账号需要有效 Premium。后端拿 token 没问题,但是读取播放状态时会被 Spotify 拒绝。最终表现就是接口只能返回默认的 Not Playing

替换方案

后来改成使用 Last.fm。

只要 Spotify 已经关联 Last.fm,播放记录会被同步到 Last.fm。后端请求 Last.fm 的 user.getRecentTracks,看第一条记录是否带有:

{
  "@attr": {
    "nowplaying": "true"
  }
}

如果有,就认为当前正在播放;如果没有,就返回 Not Playing

这样做的好处是:

  • 不再依赖 Spotify Premium。
  • 不再需要 Spotify refresh token。
  • 读最近播放、排行榜、用户统计都可以通过 Last.fm 的公开接口完成。
  • 原来的 /currently-playing 返回结构可以保持不变,前端不用改。

部署位置

服务部署在:

/srv/projects/spotify_listening

主程序:

/srv/projects/spotify_listening/spotify_listening_app.py

systemd 服务:

spotify-listening.service

监听地址:

127.0.0.1:7723

Nginx/Cloudflare 对外域名:

https://spotify.datehoer.com

这次确认后,Cloudflare 的 spotify.datehoer.com A 记录指向:

107.173.237.30

之前迁移时也有一台 107.174.71.101,但这次最终使用的是 107.173.237.30 这台机器上的 /srv/projects/spotify_listening

需要注意:这个目录权限是 root:root 700,普通用户直接 ls /srv/projects/spotify_listening 会看不到里面内容,所以容易误以为项目没有迁移过来。

当前接口

接口文档

FastAPI 自带 Swagger 文档:

https://spotify.datehoer.com/docs

OpenAPI JSON:

https://spotify.datehoer.com/openapi.json

另外加了一个轻量接口目录:

https://spotify.datehoer.com/endpoints

用于快速查看当前有哪些接口、参数和用途。

当前播放

GET /currently-playing

这个接口保留了原来前端使用的字段:

{
  "isPlaying": true,
  "title": "Heartbeat",
  "artist": "Marcus & Martinus",
  "album": "Together",
  "albumImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/xxx.png",
  "songUrl": "https://www.last.fm/music/..."
}

如果当前没有正在播放,则返回:

{
  "isPlaying": false,
  "title": "Not Playing",
  "artist": "Last.fm",
  "album": "Last.fm",
  "albumImageUrl": "https://oss.datehoer.com/blog/Spotify_Logo.png",
  "songUrl": ""
}

最近播放

GET /recent-tracks?limit=10

参数:

limit: 1-50,默认 10

返回最近 scrobble 的歌曲列表。第一首如果是当前播放,会有:

{
  "nowPlaying": true,
  "playedAt": null
}

排行榜

GET /top/tracks?period=7day&limit=10
GET /top/artists?period=7day&limit=10
GET /top/albums?period=7day&limit=10

排行榜接口的 period 支持:

overall
7day
1month
3month
6month
12month

limit 支持:

1-50,默认 10

Loved Tracks

GET /loved-tracks?limit=10

返回 Last.fm 用户喜欢的歌曲。

Profile

GET /profile

返回 Last.fm 用户信息和统计,例如:

{
  "name": "johwueubu",
  "url": "https://www.last.fm/user/johwueubu",
  "playcount": 100,
  "artistCount": 20,
  "trackCount": 50,
  "albumCount": 30
}

Summary

GET /summary?period=7day&limit=5

一次性返回:

profile
currentlyPlaying
recentTracks
topTracks
topArtists
topAlbums

这个接口适合首页、卡片组件、dashboard 一类场景,避免前端同时请求多个接口。

Health

GET /health

用于服务监控:

{
  "ok": true,
  "source": "last.fm",
  "user": "johwueubu",
  "checkedAt": "2026-05-22T01:06:28Z"
}

实现要点

核心逻辑是请求 Last.fm:

def lastfm_request(method, **params):
    request_params = {
        "method": method,
        "user": LASTFM_USERNAME,
        "api_key": LASTFM_API_KEY,
        "format": "json",
        **params,
    }
    response = requests.get(LASTFM_API_URL, params=request_params, timeout=10)
    response.raise_for_status()
    return response.json()

当前播放使用:

data = lastfm_request("user.getrecenttracks", limit=1)

然后判断第一首歌:

nowplaying = track.get("@attr", {}).get("nowplaying") == "true"

如果 nowplayingtrue,就转换成前端兼容的旧格式;否则返回 Not Playing

注意事项

Last.fm API key 可以用于公开读取接口,但 shared secret 不应该放在前端或公开文章里。当前这个服务只需要读取公开数据,不需要签名写操作,所以没有必要暴露 shared secret。

另外,Last.fm 的接口在当前播放存在时,user.getRecentTracks 可能会在请求 limit=2 时返回当前播放加两条历史记录。服务层已经做了裁剪,确保 /recent-tracks?limit=2 最终只返回 2 条。

验证

迁移后测试:

curl https://spotify.datehoer.com/currently-playing
curl https://spotify.datehoer.com/recent-tracks?limit=2
curl "https://spotify.datehoer.com/summary?period=7day&limit=2"

/docs/openapi.json/endpoints 都已经可以访问。

最终结论:这个接口现在已经从 Spotify Web API 切换到了 Last.fm。实时播放依赖 Last.fm scrobble 状态,不再受 Spotify Premium 限制。