Compare commits

...
2 Commits
Author SHA1 Message Date
DiheChen 6f286c2eb2 feat: open and import QQ Music playlists
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-11 00:30:04 +08:00
DiheChen 19a135c983 feat: read the songs in QQ Music playlists 2026-11-11 00:28:21 +08:00
28 changed files with 282 additions and 81 deletions

No files matched your search

+60
View File
@@ -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
})
}
+69
View File
@@ -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("不存在或不可见的歌单没有返回错误")
}
})
}
}
@@ -0,0 +1 @@
{"code":0,"subcode":0,"cdnum":0,"cdlist":[]}
@@ -0,0 +1 @@
{"code":10,"subcode":0,"cdnum":0,"cdlist":[]}
+1
View File
@@ -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":"歌手丙"}]}]}]}
+18 -7
View File
@@ -33,7 +33,7 @@ func (e *RejectedLinkError) Error() string { return e.Reason }
type Service struct {
netease port.NetEaseSource
qq port.Source
qq port.PlaylistSource
bilibili port.BilibiliSource
links port.BilibiliLinks
tracks port.Tracks
@@ -42,7 +42,7 @@ type Service struct {
quota port.RoomQuota
}
func New(netease port.NetEaseSource, qq port.Source, bilibili port.BilibiliSource, links port.BilibiliLinks, tracks port.Tracks, history port.PlayHistory, rooms port.RoomDirectory, quota port.RoomQuota) *Service {
func New(netease port.NetEaseSource, qq port.PlaylistSource, bilibili port.BilibiliSource, links port.BilibiliLinks, tracks port.Tracks, history port.PlayHistory, rooms port.RoomDirectory, quota port.RoomQuota) *Service {
return &Service{netease: netease, qq: qq, bilibili: bilibili, links: links, tracks: tracks, history: history, rooms: rooms, quota: quota}
}
@@ -78,8 +78,8 @@ func (s *Service) SearchPlaylists(ctx context.Context, roomID string, source tra
}
func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source track.Source, playlistID string) ([]port.Song, error) {
if source != track.NetEase {
return nil, unsupported(source, "歌单内的歌曲")
if _, err := s.playlistSource(source); err != nil {
return nil, err
}
if err := s.take(ctx, roomID); err != nil {
return nil, err
@@ -89,10 +89,11 @@ func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source trac
// 不取房间的搜索额度,因为导入歌单按成员另外限流。
func (s *Service) PlaylistSongs(ctx context.Context, source track.Source, playlistID string) ([]port.Song, error) {
if source != track.NetEase {
return nil, unsupported(source, "歌单内的歌曲")
src, err := s.playlistSource(source)
if err != nil {
return nil, err
}
songs, err := s.netease.PlaylistTracks(ctx, playlistID)
songs, err := src.PlaylistTracks(ctx, playlistID)
if err != nil {
return nil, upstream(source, "获取歌单内的歌曲", err)
}
@@ -282,6 +283,16 @@ func (s *Service) metaSource(source track.Source) (func(context.Context, string)
return nil, unsupported(source, "点歌前查询元数据")
}
func (s *Service) playlistSource(source track.Source) (port.PlaylistSource, error) {
switch source {
case track.NetEase:
return s.netease, nil
case track.QQ:
return s.qq, nil
}
return nil, unsupported(source, "歌单内的歌曲")
}
func (s *Service) searchable(source track.Source) (port.Source, error) {
switch source {
case track.NetEase:
+21 -2
View File
@@ -255,8 +255,14 @@ func TestPlaylistSongsSkipsRoomQuota(t *testing.T) {
if f.quota.calls != 0 {
t.Errorf("取了 %d 次房间的搜索额度,导入不走房间的额度", f.quota.calls)
}
if _, err := f.svc.PlaylistSongs(t.Context(), track.QQ, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("QQ 音乐的歌单 = %v,期望 ErrProviderFailed", err)
qqSongs := []port.Song{song(track.QQ, "0039MnYb0qxYhV", "晴天"), song(track.QQ, "004Z8Ihr0JIu5s", "七里香")}
f.add(f.qq, qqSongs...)
f.qq.AddPlaylist(port.Playlist{ID: "q1", Name: "周杰伦"}, "004Z8Ihr0JIu5s", "0039MnYb0qxYhV")
if got, err := f.svc.PlaylistSongs(t.Context(), track.QQ, "q1"); err != nil || !reflect.DeepEqual(got, []port.Song{qqSongs[1], qqSongs[0]}) {
t.Errorf("QQ 音乐歌单内的歌曲 = %v, %v,期望按歌单的顺序", got, err)
}
if _, err := f.svc.PlaylistSongs(t.Context(), track.Bilibili, "p1"); !errors.Is(err, port.ErrProviderFailed) {
t.Errorf("B 站的歌单 = %v,期望 ErrProviderFailed", err)
}
f.netease.Fail(errors.New("网易云不可用"))
if _, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); !errors.Is(err, port.ErrProviderFailed) {
@@ -264,6 +270,19 @@ func TestPlaylistSongsSkipsRoomQuota(t *testing.T) {
}
}
func TestPlaylistTracksTakesRoomQuota(t *testing.T) {
f := newFixture(t, history{})
songs := []port.Song{song(track.QQ, "0039MnYb0qxYhV", "晴天")}
f.add(f.qq, songs...)
f.qq.AddPlaylist(port.Playlist{ID: "q1", Name: "周杰伦"}, "0039MnYb0qxYhV")
if got, err := f.svc.PlaylistTracks(t.Context(), f.ephemeral, track.QQ, "q1"); err != nil || !reflect.DeepEqual(got, songs) || f.quota.calls != 1 {
t.Errorf("在临时房间里打开 QQ 音乐的歌单 = %v, %v,取了 %d 次额度,期望取到歌曲、取 1 次额度", got, err, f.quota.calls)
}
if _, err := f.svc.PlaylistTracks(t.Context(), f.ephemeral, track.Bilibili, "q1"); !errors.Is(err, port.ErrProviderFailed) || f.quota.calls != 1 {
t.Errorf("B 站的歌单 = %v,取了 %d 次额度,期望 ErrProviderFailed,而且不取额度", err, f.quota.calls)
}
}
func TestTrackMetaOfBilibili(t *testing.T) {
f := newFixture(t, history{})
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
+8 -18
View File
@@ -22,7 +22,7 @@ const (
var (
ErrInvalidPlaylistLink = errors.New("不是网易云或 QQ 音乐的歌单链接")
ErrUnsupportedPlaylist = errors.New("暂时只能导入网易云的歌单")
ErrUnsupportedPlaylist = errors.New("只能导入网易云或 QQ 音乐的歌单")
ErrShortLinkFailed = errors.New("没能打开这个短链接")
)
@@ -65,9 +65,9 @@ func (s *Service) ResolveLink(ctx context.Context, memberID, raw string) (track.
if err := s.registered(ctx, memberID); err != nil {
return "", "", err
}
link, err := importable(playlistlink.ParseLink(raw))
if err != nil {
return "", "", err
link, ok := playlistlink.ParseLink(raw)
if !ok {
return "", "", ErrInvalidPlaylistLink
}
if !link.Short {
return link.Source, link.ID, nil
@@ -79,9 +79,9 @@ func (s *Service) ResolveLink(ctx context.Context, memberID, raw string) (track.
if err != nil {
return "", "", fmt.Errorf("解析短链接 %s:%w:%w", link.URL, err, ErrShortLinkFailed)
}
resolved, err := importable(playlistlink.ParseLink(final))
if err != nil {
return "", "", err
resolved, ok := playlistlink.ParseLink(final)
if !ok {
return "", "", ErrInvalidPlaylistLink
}
if resolved.Short {
return "", "", fmt.Errorf("短链接 %s 停在了 %s:%w", link.URL, final, ErrShortLinkFailed)
@@ -97,7 +97,7 @@ func (s *Service) ImportPlatform(ctx context.Context, memberID, name string, sou
if err != nil {
return port.PlaylistRecord{}, 0, err
}
if source != track.NetEase {
if source != track.NetEase && source != track.QQ {
return port.PlaylistRecord{}, 0, ErrUnsupportedPlaylist
}
if err := s.take(ctx, "playlist-import:member:"+memberID, outboundBurst, outboundRefill); err != nil {
@@ -140,16 +140,6 @@ func firstOccurrences[T any](items []T, key func(T) track.Track) []T {
return kept
}
func importable(link playlistlink.Link, ok bool) (playlistlink.Link, error) {
switch {
case !ok:
return playlistlink.Link{}, ErrInvalidPlaylistLink
case link.Source != track.NetEase:
return playlistlink.Link{}, ErrUnsupportedPlaylist
}
return link, nil
}
// 曲目表里没有的歌只能用文件里的元数据。文件可以随意改写,所以地址只留平台自己的 https 地址。
func fileMeta(t ImportedTrack) track.Meta {
m := track.Meta{Title: t.Title, Artist: t.Artist, Album: t.Album, Duration: t.Duration}
+32 -14
View File
@@ -172,24 +172,24 @@ func TestResolveLink(t *testing.T) {
f.links["https://163cn.tv/abc"] = "https://music.163.com/playlist?id=456&userid=1"
f.links["https://163cn.tv/song"] = "https://music.163.com/song?id=1"
f.links["https://163cn.tv/stuck"] = "https://163cn.tv/stuck"
f.links["https://163cn.tv/qq"] = "https://y.qq.com/n/ryqq/playlist/789"
f.links["https://c6.y.qq.com/base/fcgi-bin/u?__=abc"] = "https://i.y.qq.com/n2/m/share/details/taoge.html?id=789&hosteuin=1"
linkQuota := "playlist-link:member:" + alice.ID
cases := []struct {
raw string
id string
err error
quota []string
raw string
source track.Source
id string
err error
quota []string
}{
{raw: "https://music.163.com/#/playlist?id=123", id: "123"},
{raw: "分享歌单:通勤 https://163cn.tv/abc (来自网易云音乐)", id: "456", quota: []string{linkQuota}},
{raw: "https://music.163.com/#/playlist?id=123", source: track.NetEase, id: "123"},
{raw: "分享歌单:通勤 https://163cn.tv/abc (来自网易云音乐)", source: track.NetEase, id: "456", quota: []string{linkQuota}},
{raw: "https://163cn.tv/broken", err: ErrShortLinkFailed, quota: []string{linkQuota}},
{raw: "https://163cn.tv/stuck", err: ErrShortLinkFailed, quota: []string{linkQuota}},
{raw: "https://163cn.tv/song", err: ErrInvalidPlaylistLink, quota: []string{linkQuota}},
{raw: "https://163cn.tv/qq", err: ErrUnsupportedPlaylist, quota: []string{linkQuota}},
{raw: "https://example.com/playlist?id=1", err: ErrInvalidPlaylistLink},
{raw: "随便一段文字", err: ErrInvalidPlaylistLink},
{raw: "https://y.qq.com/n/ryqq/playlist/789", err: ErrUnsupportedPlaylist},
{raw: "https://c6.y.qq.com/base/fcgi-bin/u?__=abc", err: ErrUnsupportedPlaylist},
{raw: "https://y.qq.com/n/ryqq/playlist/789", source: track.QQ, id: "789"},
{raw: "https://c6.y.qq.com/base/fcgi-bin/u?__=abc", source: track.QQ, id: "789", quota: []string{linkQuota}},
}
for _, c := range cases {
before := len(f.quota.keys)
@@ -197,8 +197,8 @@ func TestResolveLink(t *testing.T) {
if c.err != nil && !errors.Is(err, c.err) {
t.Errorf("ResolveLink(%q) = %v,期望 %v", c.raw, err, c.err)
}
if c.err == nil && (err != nil || source != track.NetEase || id != c.id) {
t.Errorf("ResolveLink(%q) = %q, %q, %v,期望网易云的歌单 %s", c.raw, source, id, err, c.id)
if c.err == nil && (err != nil || source != c.source || id != c.id) {
t.Errorf("ResolveLink(%q) = %q, %q, %v,期望 %s 的歌单 %s", c.raw, source, id, err, c.source, c.id)
}
if taken := f.quota.keys[before:]; !slices.Equal(taken, c.quota) {
t.Errorf("ResolveLink(%q) 取的额度 = %v,期望 %v:长链接在本地识别,只有解析短链接才取链接额度", c.raw, taken, c.quota)
@@ -230,6 +230,24 @@ func TestImportPlatform(t *testing.T) {
}
}
func TestImportPlatformFromQQ(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
meta := track.Meta{Title: "晴天", Artist: "周杰伦", PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV", Duration: 269 * time.Second}
f.qq.Add("0039MnYb0qxYhV", meta, "", nil)
f.qq.AddPlaylist(port.Playlist{ID: "789", Name: "周杰伦"}, "0039MnYb0qxYhV")
p, n, err := f.svc.ImportPlatform(t.Context(), alice.ID, "周杰伦", track.QQ, "789")
if err != nil || n != 1 {
t.Fatalf("ImportPlatform = %#v, %d, %v,期望导入 1 首", p, n, err)
}
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
want := []port.PlaylistItem{{Track: track.Track{Source: track.QQ, ID: "0039MnYb0qxYhV"}, Meta: meta}}
if err != nil || !slices.Equal(items, want) {
t.Errorf("导入的曲目 = %#v, %v\n期望用 QQ 音乐的元数据 %#v", items, err, want)
}
}
func TestImportPlatformTruncatesThenDedupes(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
@@ -254,8 +272,8 @@ func TestImportPlatformRejects(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
f.netease.AddPlaylist(port.Playlist{ID: "p1"}, sunny.ID)
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "QQ", track.QQ, "p1"); !errors.Is(err, ErrUnsupportedPlaylist) {
t.Errorf("QQ 音乐的歌单 = %v,期望 ErrUnsupportedPlaylist", err)
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "B 站", track.Bilibili, "p1"); !errors.Is(err, ErrUnsupportedPlaylist) {
t.Errorf("B 站的歌单 = %v,期望 ErrUnsupportedPlaylist", err)
}
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, " ", track.NetEase, "p1"); !errors.Is(err, ErrInvalidName) {
t.Errorf("空的歌单名 = %v,期望 ErrInvalidName", err)
+4 -2
View File
@@ -94,6 +94,7 @@ type fixture struct {
tracks tracksTable
quota *accountQuota
netease *porttest.Source
qq *porttest.Source
links shortLinks
}
@@ -102,12 +103,13 @@ func newFixture(t *testing.T) fixture {
store := porttest.New()
netease := porttest.NewSource(track.NetEase)
netease.Add(sunny.ID, sunnyMeta, "", nil)
qq := porttest.NewSource(track.QQ)
bilibili := porttest.NewBilibili()
tracks := tracksTable{}
music := catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, tracks, noHistory{}, store, roomQuota{})
music := catalog.New(netease, qq, bilibili, bilibili, tracks, noHistory{}, store, roomQuota{})
q := &accountQuota{}
links := shortLinks{}
return fixture{svc: New(store, store, tracks, music, q, links), store: store, tracks: tracks, quota: q, netease: netease, links: links}
return fixture{svc: New(store, store, tracks, music, q, links), store: store, tracks: tracks, quota: q, netease: netease, qq: qq, links: links}
}
func (f fixture) member(t *testing.T, username string) member.Member {
+7 -2
View File
@@ -101,10 +101,15 @@ type Source interface {
Resolve(ctx context.Context, id string) (Resolved, error)
}
// 只有网易云提供歌单内的歌曲和相似歌曲。
type NetEaseSource interface {
// 网易云和 QQ 音乐能读取歌单内的歌曲。PlaylistTracks 按歌单原本的顺序返回,至少包含前 1000 首,因为导入歌单只收前 1000 首。
type PlaylistSource interface {
Source
PlaylistTracks(ctx context.Context, playlistID string) ([]Song, error)
}
// 只有网易云提供相似歌曲。
type NetEaseSource interface {
PlaylistSource
SimilarTracks(ctx context.Context, id string) ([]Song, error)
}
+5 -1
View File
@@ -163,10 +163,14 @@ func contractSources() sources {
netease.Reject("404", "网易云没有提供这首歌的播放地址")
netease.SetSimilar("186016", "185811")
netease.AddPlaylist(port.Playlist{ID: "3778678", Name: "周杰伦精选", CoverURL: "https://p1.music.126.net/jay.jpg", Creator: "alice", Description: "", TrackCount: 2, PlayCount: 100}, "186016", "185811")
qq := porttest.NewSource(track.QQ)
qq.Add("004Z8Ihr0JIu5s", track.Meta{Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://y.gtimg.cn/music/photo_new/T002R300x300M000003DFRzD192KKD.jpg", PageURL: "https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s", Duration: 299 * time.Second},
"", nil)
qq.AddPlaylist(port.Playlist{ID: "7039422370", Name: "周杰伦精选"}, "004Z8Ihr0JIu5s")
bilibili := contractBilibili()
return sources{
netease: netease,
qq: porttest.NewSource(track.QQ),
qq: qq,
bilibili: bilibili,
bilibiliLinks: bilibili,
playback: map[track.Source]port.PlayURLs{track.NetEase: netease},
+1 -1
View File
@@ -42,7 +42,7 @@ import (
type sources struct {
netease port.NetEaseSource
qq port.Source
qq port.PlaylistSource
bilibili port.BilibiliSource
bilibiliLinks port.BilibiliLinks
playback map[track.Source]port.PlayURLs
+21 -3
View File
@@ -1,4 +1,4 @@
歌单导入:只有已注册的账号能导入。导入文件时,曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据。文件里的封面和页面链接不是平台自己的 https 地址时被丢弃,链接曲目没有封面。重复的歌只留第一次出现的位置,标题为空的跳过,来源中的第一首排在歌单最前。链接可以识别出平台歌单,目前只能导入网易云的歌单。
歌单导入:只有已注册的账号能导入。导入文件时,曲目表里已有的歌用服务端的元数据,没有的用文件里的元数据。文件里的封面和页面链接不是平台自己的 https 地址时被丢弃,链接曲目没有封面。重复的歌只留第一次出现的位置,标题为空的跳过,来源中的第一首排在歌单最前。链接可以识别出网易云和 QQ 音乐的歌单,也只能导入这两个平台的歌单。
-- request --
POST /api/members
@@ -168,11 +168,29 @@ Authorization: Bearer $alice
201
{"playlist": {"id": "$platform", "name": "周杰伦精选", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}, "imported": 2}
-- request --
POST /api/me/playlist-links
Authorization: Bearer $alice
{"link": "https://y.qq.com/n/ryqq/playlist/7039422370"}
-- response --
200
{"source": "qq", "playlistId": "7039422370"}
-- request --
POST /api/me/playlists/platform-imports
Authorization: Bearer $alice
{"name": "QQ", "source": "qq", "playlistId": "1"}
{"name": "QQ 精选", "source": "qq", "playlistId": "7039422370"}
-- response --
201
{"playlist": {"id": "$qqPlatform", "name": "QQ 精选", "favorites": false, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://y.gtimg.cn/music/photo_new/T002R300x300M000003DFRzD192KKD.jpg", "shareId": null}, "imported": 1}
-- request --
POST /api/me/playlists/platform-imports
Authorization: Bearer $alice
{"name": "B 站", "source": "bilibili", "playlistId": "1"}
-- response --
400
{"error": {"code": "INVALID_ARGUMENT", "message": "暂时只能导入网易云的歌单"}}
{"error": {"code": "INVALID_ARGUMENT", "message": "只能导入网易云或 QQ 音乐的歌单"}}
+4 -3
View File
@@ -1,5 +1,5 @@
网关直接回复搜索、歌单和推荐列表的请求,这些请求不经过房间,所以房间的事件编号不变。搜索结果在上游一次取回的结果中分页。
QQ 音乐不提供歌单内的歌曲,链接不支持搜索。推荐列表取正在播放的网易云歌曲的相似歌曲,去掉种子本身。常驻房间的搜索不限额度。
链接不支持搜索。推荐列表取正在播放的网易云歌曲的相似歌曲,去掉种子本身。常驻房间的搜索不限额度。
-- request --
POST /api/members
@@ -65,9 +65,10 @@ Authorization: Bearer $alice
{"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}},
{"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}]}}
-- send alice --
{"kind": "request", "type": "playlist.tracks", "id": "q6", "data": {"source": "qq", "playlistId": "3778678"}}
{"kind": "request", "type": "playlist.tracks", "id": "q6", "data": {"source": "qq", "playlistId": "7039422370"}}
-- receive alice --
{"kind": "response", "type": "playlist.tracks", "id": "q6", "data": {}, "error": {"code": "PROVIDER_ERROR", "message": "音乐平台没有正常响应,请稍后再试。"}}
{"kind": "response", "type": "playlist.tracks", "id": "q6", "data": {"total": 1, "items": [
{"track": {"source": "qq", "id": "004Z8Ihr0JIu5s"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://y.gtimg.cn/music/photo_new/T002R300x300M000003DFRzD192KKD.jpg", "pageUrl": "https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s", "durationMs": 299000}}]}}
-- send alice --
{"kind": "request", "type": "recommend.list", "id": "q7", "data": {}}
-- receive alice --
+4 -3
View File
@@ -19,7 +19,7 @@ type Link struct {
var (
urlPattern = regexp.MustCompile(`(?i)https?://[^\s<>"',。;!?、)】》]+`)
digits = regexp.MustCompile(`^[0-9]+$`)
qqPath = regexp.MustCompile(`^/n/(?:ryqq|yqq)/playlist/([0-9]+)(?:\.html)?$`)
qqPath = regexp.MustCompile(`^/n/(?:ryqq|ryqq_v2|yqq)/playlist/([0-9]+)(?:\.html)?$`)
)
// 重定向只能落在这些主机上,按小写比较。
@@ -30,6 +30,7 @@ var redirectHosts = map[string]bool{
"c6.y.qq.com": true,
"y.qq.com": true,
"i.y.qq.com": true,
"i2.y.qq.com": true,
}
func AllowedRedirectHost(host string) bool {
@@ -72,8 +73,8 @@ func ParseLink(raw string) (Link, bool) {
if m := qqPath.FindStringSubmatch(u.Path); m != nil {
return Link{Source: track.QQ, ID: m[1], URL: link}, true
}
case "i.y.qq.com":
if u.Path != "/n2/m/share/details/taoge.html" {
case "i.y.qq.com", "i2.y.qq.com":
if u.Path != "/n2/m/share/details/taoge.html" && u.Path != "/n3/other/pages/details/playlist.html" {
return Link{}, false
}
q := u.Query()
+5 -1
View File
@@ -20,8 +20,10 @@ func TestParseLink(t *testing.T) {
{"网易云 http", "http://music.163.com/playlist?id=123", Link{Source: track.NetEase, ID: "123", URL: "http://music.163.com/playlist?id=123"}, true},
{"QQ 桌面", "https://y.qq.com/n/ryqq/playlist/456", Link{Source: track.QQ, ID: "456", URL: "https://y.qq.com/n/ryqq/playlist/456"}, true},
{"QQ 旧版", "https://y.qq.com/n/yqq/playlist/456.html", Link{Source: track.QQ, ID: "456", URL: "https://y.qq.com/n/yqq/playlist/456.html"}, true},
{"QQ 新版网页", "https://y.qq.com/n/ryqq_v2/playlist/456?ADTAG=h5_share_playlist&mnst=1.18", Link{Source: track.QQ, ID: "456", URL: "https://y.qq.com/n/ryqq_v2/playlist/456?ADTAG=h5_share_playlist&mnst=1.18"}, true},
{"QQ 移动 id", "https://i.y.qq.com/n2/m/share/details/taoge.html?id=456&hosteuin=x", Link{Source: track.QQ, ID: "456", URL: "https://i.y.qq.com/n2/m/share/details/taoge.html?id=456&hosteuin=x"}, true},
{"QQ 移动 disstid", "https://i.y.qq.com/n2/m/share/details/taoge.html?disstid=456", Link{Source: track.QQ, ID: "456", URL: "https://i.y.qq.com/n2/m/share/details/taoge.html?disstid=456"}, true},
{"QQ 新版分享页", "https://i2.y.qq.com/n3/other/pages/details/playlist.html?app_type=qmlite&&ADTAG=myqq&id=456&hosteuin=x", Link{Source: track.QQ, ID: "456", URL: "https://i2.y.qq.com/n3/other/pages/details/playlist.html?app_type=qmlite&&ADTAG=myqq&id=456&hosteuin=x"}, true},
{"网易云短链接", "https://163cn.tv/abc", Link{Source: track.NetEase, Short: true, URL: "https://163cn.tv/abc"}, true},
{"QQ 短链接", "https://c6.y.qq.com/base/fcgi-bin/u?__=abc", Link{Source: track.QQ, Short: true, URL: "https://c6.y.qq.com/base/fcgi-bin/u?__=abc"}, true},
{"分享文本", "分享歌单:我的歌单 https://163cn.tv/abc (来自网易云音乐)", Link{Source: track.NetEase, Short: true, URL: "https://163cn.tv/abc"}, true},
@@ -37,6 +39,8 @@ func TestParseLink(t *testing.T) {
{"QQ 缺少 id", "https://y.qq.com/n/ryqq/playlist/", Link{}, false},
{"QQ id 非数字", "https://y.qq.com/n/ryqq/playlist/abc", Link{}, false},
{"QQ 移动缺少 id", "https://i.y.qq.com/n2/m/share/details/taoge.html", Link{}, false},
{"QQ 新版分享页缺少 id", "https://i2.y.qq.com/n3/other/pages/details/playlist.html?hosteuin=x", Link{}, false},
{"QQ 分享域名上的其他页面", "https://i2.y.qq.com/n3/other/pages/details/song.html?id=456", Link{}, false},
{"QQ 歌曲链接", "https://y.qq.com/n/ryqq/songDetail/abc", Link{}, false},
{"没有链接", "只是一段文字", Link{}, false},
{"空", "", Link{}, false},
@@ -56,7 +60,7 @@ func TestParseLink(t *testing.T) {
func TestAllowedRedirectHost(t *testing.T) {
for host, want := range map[string]bool{
"163cn.tv": true, "music.163.com": true, "y.music.163.com": true,
"c6.y.qq.com": true, "y.qq.com": true, "i.y.qq.com": true, "Y.QQ.com": true,
"c6.y.qq.com": true, "y.qq.com": true, "i.y.qq.com": true, "i2.y.qq.com": true, "Y.QQ.com": true,
"evil.com": false, "music.163.com.evil.com": false, "qq.com": false, "": false, "127.0.0.1": false,
} {
if got := AllowedRedirectHost(host); got != want {
+2 -2
View File
@@ -1106,7 +1106,7 @@
},
{
"name": "resolveMyPlaylistLink",
"description": "识别平台歌单的链接,短链接会跟随跳转。目前只支持网易云",
"description": "识别网易云和 QQ 音乐的歌单链接,短链接会跟随跳转",
"method": "POST",
"path": "/api/me/playlist-links",
"auth": "member",
@@ -1130,7 +1130,7 @@
},
{
"name": "importMyPlatformPlaylist",
"description": "把平台歌单导入成一个新歌单,元数据取自平台。只收前 1000 首,重复的歌只留第一次出现的位置。歌单名不合法时返回 INVALID_ARGUMENT,不会像 importMyPlaylist 那样改用“导入的歌单”。新歌单受“最多 50 个”的限制。返回实际导入的数量。目前只支持网易云",
"description": "把平台歌单导入成一个新歌单,元数据取自平台。只收前 1000 首,重复的歌只留第一次出现的位置。歌单名不合法时返回 INVALID_ARGUMENT,不会像 importMyPlaylist 那样改用“导入的歌单”。新歌单受“最多 50 个”的限制。返回实际导入的数量。只支持网易云和 QQ 音乐,其他音源返回 INVALID_ARGUMENT",
"method": "POST",
"path": "/api/me/playlists/platform-imports",
"auth": "member",
+3 -3
View File
@@ -2378,7 +2378,7 @@
},
"info": {
"title": "Alisten",
"version": "d68525cd68ef691f"
"version": "42749e16714e0d7c"
},
"openapi": "3.1.0",
"paths": {
@@ -3981,7 +3981,7 @@
"memberToken": []
}
],
"summary": "识别平台歌单的链接,短链接会跟随跳转。目前只支持网易云"
"summary": "识别网易云和 QQ 音乐的歌单链接,短链接会跟随跳转"
}
},
"/api/me/playlist-memberships": {
@@ -4184,7 +4184,7 @@
"memberToken": []
}
],
"summary": "把平台歌单导入成一个新歌单,元数据取自平台。只收前 1000 首,重复的歌只留第一次出现的位置。歌单名不合法时返回 INVALID_ARGUMENT,不会像 importMyPlaylist 那样改用“导入的歌单”。新歌单受“最多 50 个”的限制。返回实际导入的数量。目前只支持网易云"
"summary": "把平台歌单导入成一个新歌单,元数据取自平台。只收前 1000 首,重复的歌只留第一次出现的位置。歌单名不合法时返回 INVALID_ARGUMENT,不会像 importMyPlaylist 那样改用“导入的歌单”。新歌单受“最多 50 个”的限制。返回实际导入的数量。只支持网易云和 QQ 音乐,其他音源返回 INVALID_ARGUMENT"
}
},
"/api/me/playlists/{id}": {
+1 -1
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "d68525cd68ef691f"
const Version = "42749e16714e0d7c"
type APIKey struct {
Key string `json:"key"`
+3 -2
View File
@@ -32,7 +32,9 @@ describe('parseRoute', () => {
['/r/my-room/search?q=%E6%99%B4%E5%A4%A9&source=netease&view=playlists&page=2&panel=chat', { name: 'search', ref: 'my-room', panel: 'chat', query: { view: 'playlists', keyword: '晴天', source: 'netease', page: 2 } }],
['/r/my-room/search?source=netease&view=tracks', { name: 'search', ref: 'my-room', panel: null, query: { view: 'tracks', keyword: '', source: 'netease', page: 1 } }],
['/r/my-room/search?view=recommend', { name: 'search', ref: 'my-room', panel: null, query: { view: 'recommend' } }],
['/r/my-room/search?q=a&source=qq&view=playlists', { name: 'search', ref: 'my-room', panel: null, query: { view: 'playlists', keyword: 'a', source: 'qq', page: 1 } }],
['/r/my-room/playlists/netease/123?page=3&panel=queue', { name: 'playlist', ref: 'my-room', panel: 'queue', source: 'netease', id: '123', page: 3 }],
['/r/my-room/playlists/qq/123', { name: 'playlist', ref: 'my-room', panel: null, source: 'qq', id: '123', page: 1 }],
['/s/AbC_-1x', { name: 'shared-playlist', shareId: 'AbC_-1x', panel: null }],
['/s/AbC_-1x?panel=queue', { name: 'shared-playlist', shareId: 'AbC_-1x', panel: 'queue' }],
])('%s', (url, route) => {
@@ -50,7 +52,6 @@ describe('parseRoute', () => {
['/r/my-room?panel=lyrics', '/r/my-room'],
['/r/my-room/search?q=%20a%20', '/r/my-room/search?q=a&source=qq'],
['/r/my-room/search?q=a&source=kuwo&view=lyrics&page=0', '/r/my-room/search?q=a&source=qq'],
['/r/my-room/search?q=a&source=qq&view=playlists', '/r/my-room/search?q=a&source=qq'],
['/r/my-room/search?q=a&page=2', '/r/my-room/search?q=a&source=qq'],
['/r/my-room/search?view=tracks&page=2', '/r/my-room/search?view=tracks'],
['/r/my-room/search?view=recommend&q=a&source=netease&page=2', '/r/my-room/search?view=recommend'],
@@ -64,7 +65,7 @@ describe('parseRoute', () => {
expect(route && formatRoute(route)).toBe(canonical)
})
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/settings/', '/settings/account/extra', '/admin/', '/admin/overview', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/settings/', '/settings/account/extra', '/admin/', '/admin/overview', '/r/my-room/playlists/bilibili/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
expect(parse(url)).toBeNull()
})
+2 -2
View File
@@ -3,7 +3,7 @@ import type { AdminSection } from '@/features/admin'
import type { SearchSource } from '@/features/history'
import type { SearchQuery } from '@/features/search'
import type { SettingsSection } from '@/features/settings'
import { canOpenPlaylists, defaultSearchSource, keywordSearch } from '@/features/search'
import { defaultSearchSource, keywordSearch } from '@/features/search'
export type RoomPage = 'listen' | 'queue' | 'chat'
export type Panel = 'queue' | 'history' | 'chat'
@@ -144,7 +144,7 @@ export function parseRoute({ pathname, search, hash }: Pick<Location, 'pathname'
}
const playlist = /^\/playlists\/([^/]+)\/([^/]+)$/.exec(subpath)
const source = parseSource(playlist?.[1] ?? null)
if (!playlist || !source || !canOpenPlaylists(source))
if (!playlist || !source)
return null
return { name: 'playlist', ref, panel, source, id: playlist[2], page: parsePage(params) }
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = 'd68525cd68ef691f'
export const PROTOCOL_VERSION = '42749e16714e0d7c'
export interface APIKey {
key: string
+1 -1
View File
@@ -95,7 +95,7 @@ function ImportLinkDialog({ onClose, onImported }: { onClose: () => void, onImpo
>
<label>
歌单链接
<input data-autofocus required placeholder="粘贴网易云音乐的歌单链接或分享文字" value={link} onChange={event => setLink(event.target.value)} />
<input data-autofocus required placeholder="粘贴网易云音乐或 QQ 音乐的歌单链接、分享文字" value={link} onChange={event => setLink(event.target.value)} />
</label>
<label>
歌单名
+1 -1
View File
@@ -1,7 +1,7 @@
import { lazyPage } from '@/shared/ui'
export { HeaderSearch } from './header-search'
export { canOpenPlaylists, defaultSearchSource, keywordSearch, searchStore } from './search-store'
export { defaultSearchSource, keywordSearch, searchStore } from './search-store'
export type { SearchQuery } from './search-store'
export const SearchPage = lazyPage(() => import('./search-page').then(module => module.SearchPage))
export const PlaylistPage = lazyPage(() => import('./search-page').then(module => module.PlaylistPage))
+2 -2
View File
@@ -11,7 +11,7 @@ import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, EmptyState, IconButton, PlayingBars, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
import { SearchBox } from './search-box'
import { canOpenPlaylists, keywordSearch, pageSize, recordSearch, searchStore } from './search-store'
import { keywordSearch, pageSize, recordSearch, searchStore } from './search-store'
import './search.css'
const categories: { view: SearchCategory, label: string }[] = [
@@ -278,7 +278,7 @@ export function SearchPage({ inlineSearchClassName, onSearch, onOpenPlaylist }:
<div className="search-filter-bar">
<SearchBox className={`search-inline ${inlineSearchClassName}`} recent={false} onSearch={onSearch} />
<div className="search-filter-chips" role="group" aria-label="搜索类别">
{categories.filter(category => category.view !== 'playlists' || canOpenPlaylists(source)).map(category => (
{categories.map(category => (
<button
type="button"
key={category.view}
+2 -2
View File
@@ -255,10 +255,10 @@ describe('搜索请求', () => {
expect(notificationStore.state.notifications).toHaveLength(1)
})
it('只有网易云搜歌单:其他来源的全部类别只搜歌曲', async () => {
it('搜 QQ 音乐的全部类别时,也同时搜歌曲和歌单', async () => {
const { searchStore, keywordSearch } = await load()
searchStore.show(keywordSearch('a', 'qq', 'all'))
expect(calls.map(({ name }) => name)).toEqual(['searchTracks'])
expect(calls.map(({ name }) => name)).toEqual(['searchTracks', 'searchPlaylists'])
})
it('关键词太长时提示长度上限', async () => {
+2 -7
View File
@@ -58,13 +58,8 @@ interface SearchState extends Results {
const noResults: Results = { tracks: [], tracksTotal: 0, playlists: [], playlistsTotal: 0, recommendations: [] }
// 只有网易云能取歌单内的歌曲,其他平台的歌单搜到了也打不开,所以不搜。
export function canOpenPlaylists(source: Source): boolean {
return source === 'netease'
}
export function keywordSearch(keyword: string, source: SearchSource, category: SearchCategory, page = 1): KeywordQuery {
return { view: category === 'playlists' && !canOpenPlaylists(source) ? 'all' : category, keyword, source, page }
return { view: category, keyword, source, page }
}
// 用户发起的搜索记入搜索历史,包括提交、点最近搜索、换来源和类别。翻页、推荐、打开歌单,以及前进、后退还原的搜索都不记。
@@ -223,7 +218,7 @@ class SearchStore extends Store<SearchState> {
const tracks = (at: number) => requests.searchTracks({ source, keyword, page: at, pageSize }).then(({ items, total }) => ({ tracks: items, tracksTotal: total }))
const playlists = (at: number) => requests.searchPlaylists({ source, keyword, page: at, pageSize }).then(({ items, total }) => ({ playlists: items, playlistsTotal: total }))
if (view === 'all')
return canOpenPlaylists(source) ? [tracks(1), playlists(1)] : [tracks(1)]
return [tracks(1), playlists(1)]
return [view === 'tracks' ? tracks(page) : playlists(page)]
}