Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f286c2eb2 | ||
|
|
19a135c983 |
No files matched your search
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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":[]}
|
||||
@@ -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":"歌手丙"}]}]}]}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
@@ -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
@@ -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 --
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}": {
|
||||
|
||||
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "d68525cd68ef691f"
|
||||
const Version = "42749e16714e0d7c"
|
||||
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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))
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user