From 19a135c9839236cd504253a9e0fb031120402662 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:28:21 +0800 Subject: [PATCH] feat: read the songs in QQ Music playlists --- internal/adapter/out/qq/playlist.go | 60 ++++++++++++++++ internal/adapter/out/qq/playlist_test.go | 69 +++++++++++++++++++ .../qq/testdata/playlist-tracks-empty.json | 1 + .../qq/testdata/playlist-tracks-missing.json | 1 + .../out/qq/testdata/playlist-tracks.json | 1 + 5 files changed, 132 insertions(+) create mode 100644 internal/adapter/out/qq/playlist.go create mode 100644 internal/adapter/out/qq/playlist_test.go create mode 100644 internal/adapter/out/qq/testdata/playlist-tracks-empty.json create mode 100644 internal/adapter/out/qq/testdata/playlist-tracks-missing.json create mode 100644 internal/adapter/out/qq/testdata/playlist-tracks.json diff --git a/internal/adapter/out/qq/playlist.go b/internal/adapter/out/qq/playlist.go new file mode 100644 index 0000000..572344a --- /dev/null +++ b/internal/adapter/out/qq/playlist.go @@ -0,0 +1,60 @@ +package qq + +import ( + "context" + "errors" + "fmt" + "net/url" + + "github.com/dihemoe/alisten/internal/app/port" +) + +const playlistDetailAPI = "https://i.y.qq.com/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg" + +type playlistDetailResponse struct { + Code int `json:"code"` + Cdlist []struct { + Songlist []struct { + SongMID string `json:"songmid"` + SongName string `json:"songname"` + AlbumName string `json:"albumname"` + AlbumMID string `json:"albummid"` + Interval int64 `json:"interval"` + Singer []singer `json:"singer"` + } `json:"songlist"` + } `json:"cdlist"` +} + +func (r playlistDetailResponse) check() error { + if r.Code != 0 { + return fmt.Errorf("QQ 音乐返回错误码 %d", r.Code) + } + return nil +} + +func (c *Client) PlaylistTracks(ctx context.Context, playlistID string) ([]port.Song, error) { + return cached(c, "playlist_tracks "+playlistID, func() ([]port.Song, error) { + var resp playlistDetailResponse + query := url.Values{ + "type": {"1"}, "json": {"1"}, "utf8": {"1"}, "onlysong": {"0"}, "disstid": {playlistID}, + "format": {"json"}, "g_tk": {"5381"}, "loginUin": {"0"}, "hostUin": {"0"}, + "inCharset": {"utf8"}, "outCharset": {"utf-8"}, "notice": {"0"}, "platform": {"yqq"}, "needNewCode": {"0"}, + } + if err := c.get(ctx, "qq", "playlist_tracks", playlistDetailAPI, query, desktopHeader, &resp); err != nil { + return nil, err + } + if len(resp.Cdlist) == 0 { + return nil, errors.New("QQ 音乐的歌单不存在或不可见") + } + // 这个接口一次返回全部曲目,实测 1137 首的歌单也完整,不需要翻页。 + songs := make([]port.Song, 0, len(resp.Cdlist[0].Songlist)) + for _, s := range resp.Cdlist[0].Songlist { + // 没有 songmid 的条目是下架等不可用的歌,丢弃但保持其余歌曲的原顺序。 + if s.SongMID == "" { + continue + } + songs = append(songs, song(s.SongMID, s.SongName, s.Singer, s.AlbumName, s.AlbumMID, s.Interval)) + } + return songs, nil + }) +} diff --git a/internal/adapter/out/qq/playlist_test.go b/internal/adapter/out/qq/playlist_test.go new file mode 100644 index 0000000..787e21d --- /dev/null +++ b/internal/adapter/out/qq/playlist_test.go @@ -0,0 +1,69 @@ +package qq + +import ( + "net/http" + "reflect" + "testing" + "time" + + "github.com/dihemoe/alisten/internal/app/port" + "github.com/dihemoe/alisten/internal/domain/track" +) + +func TestPlaylistTracks(t *testing.T) { + c, obs := serve(t, map[string]http.HandlerFunc{ + "/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg": func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("disstid"); got != "7382629476" { + t.Errorf("歌单 ID = %q,期望与搜索返回的 dissid 一致", got) + } + fixture(t, "playlist-tracks.json")(w, r) + }, + }) + want := []port.Song{ + { + Track: track.Track{Source: track.QQ, ID: "mid1"}, + Meta: track.Meta{ + Title: "第一首", Artist: "歌手甲, 歌手乙", Album: "专辑一", + CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000album1.jpg", + PageURL: "https://y.qq.com/n/ryqq/songDetail/mid1", + Duration: 200 * time.Second, + }, + }, + { + Track: track.Track{Source: track.QQ, ID: "mid2"}, + Meta: track.Meta{ + Title: "第二首", Artist: "歌手丙", Album: "专辑二", + CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000album2.jpg", + PageURL: "https://y.qq.com/n/ryqq/songDetail/mid2", + Duration: 100 * time.Second, + }, + }, + } + // 缓存命中时第二次调用不再发请求。 + for range 2 { + got, err := c.PlaylistTracks(t.Context(), "7382629476") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("歌单内的歌曲 = %#v\n期望 %#v", got, want) + } + } + obs.check(t, "qq playlist_tracks") +} + +func TestPlaylistTracksUnavailable(t *testing.T) { + for name, file := range map[string]string{ + "接口返回错误码": "playlist-tracks-missing.json", + "cdlist 为空": "playlist-tracks-empty.json", + } { + t.Run(name, func(t *testing.T) { + c, _ := serve(t, map[string]http.HandlerFunc{ + "/qzone-music/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg": fixture(t, file), + }) + if _, err := c.PlaylistTracks(t.Context(), "999999999999"); err == nil { + t.Error("不存在或不可见的歌单没有返回错误") + } + }) + } +} diff --git a/internal/adapter/out/qq/testdata/playlist-tracks-empty.json b/internal/adapter/out/qq/testdata/playlist-tracks-empty.json new file mode 100644 index 0000000..64eaff3 --- /dev/null +++ b/internal/adapter/out/qq/testdata/playlist-tracks-empty.json @@ -0,0 +1 @@ +{"code":0,"subcode":0,"cdnum":0,"cdlist":[]} diff --git a/internal/adapter/out/qq/testdata/playlist-tracks-missing.json b/internal/adapter/out/qq/testdata/playlist-tracks-missing.json new file mode 100644 index 0000000..e7cfd54 --- /dev/null +++ b/internal/adapter/out/qq/testdata/playlist-tracks-missing.json @@ -0,0 +1 @@ +{"code":10,"subcode":0,"cdnum":0,"cdlist":[]} diff --git a/internal/adapter/out/qq/testdata/playlist-tracks.json b/internal/adapter/out/qq/testdata/playlist-tracks.json new file mode 100644 index 0000000..70cf1e8 --- /dev/null +++ b/internal/adapter/out/qq/testdata/playlist-tracks.json @@ -0,0 +1 @@ +{"code":0,"subcode":0,"cdnum":1,"cdlist":[{"dissname":"测试歌单","songlist":[{"songmid":"mid1","songname":"第一首","albumname":"专辑一","albummid":"album1","interval":200,"singer":[{"name":"歌手甲"},{"name":"歌手乙"}]},{"songmid":"","songname":"已下架的歌","albumname":"","albummid":"","interval":0,"singer":[]},{"songmid":"mid2","songname":"第二首","albumname":"专辑二","albummid":"album2","interval":100,"singer":[{"name":"歌手丙"}]}]}]}