diff --git a/internal/app/catalog/catalog.go b/internal/app/catalog/catalog.go index a133571..9226770 100644 --- a/internal/app/catalog/catalog.go +++ b/internal/app/catalog/catalog.go @@ -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: diff --git a/internal/app/catalog/catalog_test.go b/internal/app/catalog/catalog_test.go index c2d2209..9c25ebe 100644 --- a/internal/app/catalog/catalog_test.go +++ b/internal/app/catalog/catalog_test.go @@ -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"} diff --git a/internal/app/playlist/import.go b/internal/app/playlist/import.go index cf986a9..8cd838e 100644 --- a/internal/app/playlist/import.go +++ b/internal/app/playlist/import.go @@ -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} diff --git a/internal/app/playlist/import_test.go b/internal/app/playlist/import_test.go index a2881da..19f9898 100644 --- a/internal/app/playlist/import_test.go +++ b/internal/app/playlist/import_test.go @@ -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) diff --git a/internal/app/playlist/playlist_test.go b/internal/app/playlist/playlist_test.go index 9be52fe..a0f712e 100644 --- a/internal/app/playlist/playlist_test.go +++ b/internal/app/playlist/playlist_test.go @@ -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 { diff --git a/internal/app/port/source.go b/internal/app/port/source.go index 48a3518..14b26b2 100644 --- a/internal/app/port/source.go +++ b/internal/app/port/source.go @@ -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) } diff --git a/internal/bootstrap/contract_test.go b/internal/bootstrap/contract_test.go index 0786a5a..d5e8c29 100644 --- a/internal/bootstrap/contract_test.go +++ b/internal/bootstrap/contract_test.go @@ -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}, diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index dd5f82a..da74dd4 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -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 diff --git a/internal/bootstrap/testdata/http/playlist-imports.txtar b/internal/bootstrap/testdata/http/playlist-imports.txtar index 61aea83..c1a8092 100644 --- a/internal/bootstrap/testdata/http/playlist-imports.txtar +++ b/internal/bootstrap/testdata/http/playlist-imports.txtar @@ -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 音乐的歌单"}} diff --git a/internal/bootstrap/testdata/ws/catalog.txtar b/internal/bootstrap/testdata/ws/catalog.txtar index 2110177..c7bc44c 100644 --- a/internal/bootstrap/testdata/ws/catalog.txtar +++ b/internal/bootstrap/testdata/ws/catalog.txtar @@ -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 -- diff --git a/internal/domain/playlist/link.go b/internal/domain/playlist/link.go index faf9920..4498445 100644 --- a/internal/domain/playlist/link.go +++ b/internal/domain/playlist/link.go @@ -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() diff --git a/internal/domain/playlist/link_test.go b/internal/domain/playlist/link_test.go index 08a1c63..93b3895 100644 --- a/internal/domain/playlist/link_test.go +++ b/internal/domain/playlist/link_test.go @@ -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 { diff --git a/protocol/idl.json b/protocol/idl.json index 8a39dbd..871eb7e 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -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", diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 8118553..f60d5c7 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -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}": { diff --git a/protocol/types.gen.go b/protocol/types.gen.go index 4362f8c..7eee7a9 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "d68525cd68ef691f" +const Version = "42749e16714e0d7c" type APIKey struct { Key string `json:"key"` diff --git a/web/src/app/route.test.ts b/web/src/app/route.test.ts index f81ba8b..5d4bd35 100644 --- a/web/src/app/route.test.ts +++ b/web/src/app/route.test.ts @@ -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() }) diff --git a/web/src/app/route.ts b/web/src/app/route.ts index 3b34467..ea65c62 100644 --- a/web/src/app/route.ts +++ b/web/src/app/route.ts @@ -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 void, onImpo >