feat: import playlists from files and from NetEase links
This commit is contained in:
1 parent
ed49e3520f
commit
2bf241ae3c
11 files changed
+605
-20
No files matched your search
@@ -158,7 +158,7 @@ func TestRequestBodyLimit(t *testing.T) {
|
||||
func TestPlaylistBatchBodyLimit(t *testing.T) {
|
||||
store := porttest.New()
|
||||
s := services(store, store, "")
|
||||
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil)
|
||||
s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil, nil)
|
||||
server, _ := start(t, s)
|
||||
var created protocol.CreateMemberResponse
|
||||
if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
|
||||
|
||||
@@ -17,6 +17,10 @@ func (noTracks) PreparedTrack(context.Context, track.Track) (port.PreparedTrack,
|
||||
return port.PreparedTrack{}, false, nil
|
||||
}
|
||||
|
||||
func (noTracks) TrackMetas(context.Context, []track.Track) (map[track.Track]track.Meta, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (noTracks) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -84,6 +84,14 @@ func (s *Service) PlaylistTracks(ctx context.Context, roomID string, source trac
|
||||
if err := s.take(ctx, roomID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.PlaylistSongs(ctx, source, playlistID)
|
||||
}
|
||||
|
||||
// 不取房间的搜索额度:导入歌单按成员另外限流。
|
||||
func (s *Service) PlaylistSongs(ctx context.Context, source track.Source, playlistID string) ([]port.Song, error) {
|
||||
if source != track.NetEase {
|
||||
return nil, unsupported(source, "歌单内的歌曲")
|
||||
}
|
||||
songs, err := s.netease.PlaylistTracks(ctx, playlistID)
|
||||
if err != nil {
|
||||
return nil, upstream(source, "获取歌单内的歌曲", err)
|
||||
|
||||
@@ -244,6 +244,26 @@ func TestFirstMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistSongsSkipsRoomQuota(t *testing.T) {
|
||||
f := newFixture(t, history{})
|
||||
songs := []port.Song{song(track.NetEase, "1", "晴天"), song(track.NetEase, "2", "七里香")}
|
||||
f.add(f.netease, songs...)
|
||||
f.netease.AddPlaylist(port.Playlist{ID: "p1", Name: "周杰伦"}, "2", "1")
|
||||
if got, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); err != nil || !reflect.DeepEqual(got, []port.Song{songs[1], songs[0]}) {
|
||||
t.Errorf("歌单内的歌曲 = %v, %v,期望按歌单的顺序", got, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
f.netease.Fail(errors.New("网易云不可用"))
|
||||
if _, err := f.svc.PlaylistSongs(t.Context(), track.NetEase, "p1"); !errors.Is(err, port.ErrProviderFailed) {
|
||||
t.Errorf("上游出错时的错误 = %v,期望 ErrProviderFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackMetaOfBilibili(t *testing.T) {
|
||||
f := newFixture(t, history{})
|
||||
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
playlistlink "github.com/dihemoe/alisten/internal/domain/playlist"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultImportName = "导入的歌单"
|
||||
// 解析短链接和导入平台歌单都要访问外部,各自按成员限流,额度相同。
|
||||
outboundBurst = 10
|
||||
outboundRefill = 6 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPlaylistLink = errors.New("不是网易云或 QQ 音乐的歌单链接")
|
||||
ErrUnsupportedPlaylist = errors.New("暂时只能导入网易云的歌单")
|
||||
ErrShortLinkFailed = errors.New("没能打开这个短链接")
|
||||
)
|
||||
|
||||
type ImportedTrack struct {
|
||||
Track track.Track
|
||||
Title, Artist, Album, CoverURL, PageURL string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// 链接曲目的 Track.ID 必须已经按 track.LinkID 规范化。
|
||||
func (s *Service) ImportFile(ctx context.Context, memberID, name string, tracks []ImportedTrack) (port.PlaylistRecord, int, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return port.PlaylistRecord{}, 0, err
|
||||
}
|
||||
name, err := validName(name)
|
||||
if err != nil {
|
||||
name = defaultImportName
|
||||
}
|
||||
tracks = firstOccurrences(tracks, func(t ImportedTrack) track.Track { return t.Track })
|
||||
keys := make([]track.Track, len(tracks))
|
||||
for i, t := range tracks {
|
||||
keys[i] = t.Track
|
||||
}
|
||||
known, err := s.tracks.TrackMetas(ctx, keys)
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, 0, err
|
||||
}
|
||||
items := make([]port.PlaylistItem, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
if m, ok := known[t.Track]; ok {
|
||||
items = append(items, port.PlaylistItem{Track: t.Track, Meta: m})
|
||||
} else if strings.TrimSpace(t.Title) != "" {
|
||||
items = append(items, port.PlaylistItem{Track: t.Track, Meta: fileMeta(t)})
|
||||
}
|
||||
}
|
||||
return s.importItems(ctx, memberID, name, items)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveLink(ctx context.Context, memberID, raw string) (track.Source, string, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
link, err := importable(playlistlink.ParseLink(raw))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !link.Short {
|
||||
return link.Source, link.ID, nil
|
||||
}
|
||||
if err := s.take(ctx, "playlist-link:member:"+memberID, outboundBurst, outboundRefill); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
final, err := s.shortLinks.Resolve(ctx, link.URL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("解析短链接 %s:%w:%w", link.URL, err, ErrShortLinkFailed)
|
||||
}
|
||||
resolved, err := importable(playlistlink.ParseLink(final))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if resolved.Short {
|
||||
return "", "", fmt.Errorf("短链接 %s 停在了 %s:%w", link.URL, final, ErrShortLinkFailed)
|
||||
}
|
||||
return resolved.Source, resolved.ID, nil
|
||||
}
|
||||
|
||||
func (s *Service) ImportPlatform(ctx context.Context, memberID, name string, source track.Source, playlistID string) (port.PlaylistRecord, int, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return port.PlaylistRecord{}, 0, err
|
||||
}
|
||||
name, err := validName(name)
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, 0, err
|
||||
}
|
||||
if source != track.NetEase {
|
||||
return port.PlaylistRecord{}, 0, ErrUnsupportedPlaylist
|
||||
}
|
||||
if err := s.take(ctx, "playlist-import:member:"+memberID, outboundBurst, outboundRefill); err != nil {
|
||||
return port.PlaylistRecord{}, 0, err
|
||||
}
|
||||
songs, err := s.catalog.PlaylistSongs(ctx, source, playlistID)
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, 0, err
|
||||
}
|
||||
songs = firstOccurrences(songs, func(song port.Song) track.Track { return song.Track })
|
||||
items := make([]port.PlaylistItem, len(songs))
|
||||
for i, song := range songs {
|
||||
items[i] = port.PlaylistItem(song)
|
||||
}
|
||||
return s.importItems(ctx, memberID, name, items)
|
||||
}
|
||||
|
||||
func (s *Service) importItems(ctx context.Context, memberID, name string, items []port.PlaylistItem) (port.PlaylistRecord, int, error) {
|
||||
p, err := s.playlists.ImportPlaylist(ctx, memberID, name, items, maxPlaylists)
|
||||
if errors.Is(err, port.ErrPlaylistLimit) {
|
||||
return port.PlaylistRecord{}, 0, ErrTooManyPlaylists
|
||||
}
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, 0, err
|
||||
}
|
||||
return p, len(items), nil
|
||||
}
|
||||
|
||||
// 只看前 maxItems 首,其中重复的歌只留第一次出现的位置。
|
||||
func firstOccurrences[T any](items []T, key func(T) track.Track) []T {
|
||||
items = items[:min(len(items), maxItems)]
|
||||
seen := make(map[track.Track]bool, len(items))
|
||||
kept := make([]T, 0, len(items))
|
||||
for _, item := range items {
|
||||
if k := key(item); !seen[k] {
|
||||
seen[k] = true
|
||||
kept = append(kept, item)
|
||||
}
|
||||
}
|
||||
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}
|
||||
switch id := t.Track.ID; t.Track.Source {
|
||||
case track.NetEase:
|
||||
m.CoverURL = platformURL(t.CoverURL, func(host string) bool { return strings.HasSuffix(host, ".music.126.net") })
|
||||
if m.PageURL = platformURL(t.PageURL, isHost("music.163.com")); m.PageURL == "" {
|
||||
m.PageURL = track.NetEasePageURL(id)
|
||||
}
|
||||
case track.QQ:
|
||||
m.CoverURL = platformURL(t.CoverURL, isHost("y.gtimg.cn"))
|
||||
if m.PageURL = platformURL(t.PageURL, isHost("y.qq.com")); m.PageURL == "" {
|
||||
m.PageURL = track.QQPageURL(id)
|
||||
}
|
||||
// B 站和链接曲目的封面存在导出站点的对象存储里,换一个站点就失效。
|
||||
case track.Bilibili:
|
||||
m.PageURL = track.BilibiliPageURL(id)
|
||||
case track.Link:
|
||||
m.PageURL = id
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func platformURL(raw string, allowed func(host string) bool) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.User != nil || u.Port() != "" || !allowed(strings.ToLower(u.Hostname())) {
|
||||
return ""
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func isHost(want string) func(string) bool {
|
||||
return func(host string) bool { return host == want }
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
func netease(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
|
||||
|
||||
func qq(id string) track.Track { return track.Track{Source: track.QQ, ID: id} }
|
||||
|
||||
func itemTracks(items []port.PlaylistItem) []track.Track {
|
||||
tracks := make([]track.Track, len(items))
|
||||
for i, item := range items {
|
||||
tracks[i] = item.Track
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
func TestImportFileChoosesMeta(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
clipMeta := track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: clip.ID, Duration: time.Minute}
|
||||
knownMeta := track.Meta{Title: "已知", PageURL: "https://y.qq.com/n/ryqq/songDetail/known"}
|
||||
f.tracks[sunny] = port.PreparedTrack{Meta: sunnyMeta}
|
||||
f.tracks[clip] = port.PreparedTrack{Meta: clipMeta}
|
||||
f.tracks[qq("known")] = port.PreparedTrack{Meta: knownMeta}
|
||||
video := track.Track{Source: track.Bilibili, ID: "BV1xx411c7mD/2"}
|
||||
other := track.Track{Source: track.Link, ID: "https://example.com/b.mp3"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in ImportedTrack
|
||||
want *track.Meta // nil 表示跳过
|
||||
}{
|
||||
{"曲目表里有:完全用服务端的元数据",
|
||||
ImportedTrack{Track: sunny, Title: "文件里的标题", Artist: "别人", CoverURL: "https://p1.music.126.net/other.jpg", Duration: time.Second},
|
||||
&sunnyMeta},
|
||||
{"链接曲目在曲目表里:用服务端的封面",
|
||||
ImportedTrack{Track: clip, Title: "文件里的标题", CoverURL: "https://evil.example/a.jpg"},
|
||||
&clipMeta},
|
||||
{"曲目表里有:文件里的标题为空也不跳过",
|
||||
ImportedTrack{Track: qq("known")},
|
||||
&knownMeta},
|
||||
{"网易云:平台的 https 地址保留",
|
||||
ImportedTrack{Track: netease("185811"), Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://p2.music.126.net/b.jpg", PageURL: "https://music.163.com/song?id=185811", Duration: 299 * time.Second},
|
||||
&track.Meta{Title: "七里香", Artist: "周杰伦", Album: "七里香", CoverURL: "https://p2.music.126.net/b.jpg", PageURL: "https://music.163.com/song?id=185811", Duration: 299 * time.Second}},
|
||||
{"网易云:http 和伪装的域名丢弃,页面链接由 ID 推出",
|
||||
ImportedTrack{Track: netease("1"), Title: "一", CoverURL: "http://p1.music.126.net/a.jpg", PageURL: "https://music.163.com.evil.example/song?id=1"},
|
||||
&track.Meta{Title: "一", PageURL: "https://music.163.com/#/song?id=1"}},
|
||||
{"网易云:封面只认 music.126.net 的子域名,带 userinfo 的地址丢弃",
|
||||
ImportedTrack{Track: netease("2"), Title: "二", CoverURL: "https://music.126.net.evil.example/a.jpg", PageURL: "https://user@music.163.com/song?id=2"},
|
||||
&track.Meta{Title: "二", PageURL: "https://music.163.com/#/song?id=2"}},
|
||||
{"网易云:封面主机只是字面上以 music.126.net 结尾的丢弃",
|
||||
ImportedTrack{Track: netease("6"), Title: "六", CoverURL: "https://evilmusic.126.net/a.jpg", PageURL: "https://music.163.com/song?id=6"},
|
||||
&track.Meta{Title: "六", PageURL: "https://music.163.com/song?id=6"}},
|
||||
{"网易云:带端口的地址丢弃",
|
||||
ImportedTrack{Track: netease("3"), Title: "三", CoverURL: "https://p1.music.126.net:8443/a.jpg", PageURL: "https://music.163.com:8443/song?id=3"},
|
||||
&track.Meta{Title: "三", PageURL: "https://music.163.com/#/song?id=3"}},
|
||||
{"QQ 音乐:平台的 https 地址保留,域名不区分大小写",
|
||||
ImportedTrack{Track: qq("0039MnYb0qxYhV"), Title: "晴天", CoverURL: "https://Y.GTIMG.CN/music/a.jpg", PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"},
|
||||
&track.Meta{Title: "晴天", CoverURL: "https://Y.GTIMG.CN/music/a.jpg", PageURL: "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"}},
|
||||
{"QQ 音乐:别家平台的地址也丢弃",
|
||||
ImportedTrack{Track: qq("002"), Title: "二", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=2"},
|
||||
&track.Meta{Title: "二", PageURL: "https://y.qq.com/n/ryqq/songDetail/002"}},
|
||||
{"B 站:页面链接由 ID 推出,封面丢弃",
|
||||
ImportedTrack{Track: video, Title: "晴天(钢琴版)", Artist: "某 UP 主", CoverURL: "https://i0.hdslb.com/a.jpg", PageURL: "https://www.bilibili.com/video/BV1xx411c7mD", Duration: 2 * time.Minute},
|
||||
&track.Meta{Title: "晴天(钢琴版)", Artist: "某 UP 主", PageURL: "https://www.bilibili.com/video/BV1xx411c7mD?p=2", Duration: 2 * time.Minute}},
|
||||
{"链接曲目:页面链接就是 ID,封面丢弃",
|
||||
ImportedTrack{Track: other, Title: "b.mp3", CoverURL: "https://example.com/b.jpg", PageURL: "https://evil.example/"},
|
||||
&track.Meta{Title: "b.mp3", PageURL: other.ID}},
|
||||
{"曲目表里没有、标题为空:跳过",
|
||||
ImportedTrack{Track: netease("4"), Artist: "无名"},
|
||||
nil},
|
||||
{"曲目表里没有、标题只有空白:跳过",
|
||||
ImportedTrack{Track: netease("5"), Title: " \t"},
|
||||
nil},
|
||||
}
|
||||
in := make([]ImportedTrack, len(cases))
|
||||
var want []track.Track
|
||||
for i, c := range cases {
|
||||
in[i] = c.in
|
||||
if c.want != nil {
|
||||
want = append(want, c.in.Track)
|
||||
}
|
||||
}
|
||||
p, n, err := f.svc.ImportFile(t.Context(), alice.ID, "导入", in)
|
||||
if err != nil || n != len(want) || p.TrackCount != len(want) {
|
||||
t.Fatalf("ImportFile = %#v, %d, %v,期望导入 %d 首", p, n, err, len(want))
|
||||
}
|
||||
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := itemTracks(items); !slices.Equal(got, want) {
|
||||
t.Errorf("导入的曲目 = %v,期望与文件同序 %v", got, want)
|
||||
}
|
||||
for _, c := range cases {
|
||||
i := slices.IndexFunc(items, func(item port.PlaylistItem) bool { return item.Track == c.in.Track })
|
||||
switch {
|
||||
case c.want == nil && i >= 0:
|
||||
t.Errorf("%s:导入了 %#v", c.name, items[i].Meta)
|
||||
case c.want != nil && i < 0:
|
||||
t.Errorf("%s:没有导入", c.name)
|
||||
case c.want != nil && items[i].Meta != *c.want:
|
||||
t.Errorf("%s:元数据 = %#v\n期望 %#v", c.name, items[i].Meta, *c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportFileTruncatesThenKeepsFirstOccurrences(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
in := []ImportedTrack{{Track: netease("b"), Title: "B"}, {Track: netease("a"), Title: "A"}, {Track: netease("b"), Title: "又一个 B"}}
|
||||
for i := range 999 {
|
||||
in = append(in, ImportedTrack{Track: netease(strconv.Itoa(i)), Title: "歌"})
|
||||
}
|
||||
p, n, err := f.svc.ImportFile(t.Context(), alice.ID, "导入", in)
|
||||
if err != nil || n != 999 {
|
||||
t.Fatalf("ImportFile = %#v, %d, %v,期望前 1000 首去掉 1 首重复的,导入 999 首", p, n, err)
|
||||
}
|
||||
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items[0].Meta.Title != "B" || items[1].Track != netease("a") || items[2].Track != netease("0") || items[len(items)-1].Track != netease("996") {
|
||||
t.Errorf("导入的曲目以 %v 开头、%v 结尾,期望重复的歌留在第一次出现的位置、第 1000 首之后的不导入", itemTracks(items[:3]), items[len(items)-1].Track)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportFileFallsBackToDefaultName(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
song := []ImportedTrack{{Track: sunny, Title: "晴天"}}
|
||||
for _, name := range []string{"", " ", strings.Repeat("歌", 51), "通\n勤", "\u200b"} {
|
||||
if p, _, err := f.svc.ImportFile(t.Context(), alice.ID, name, song); err != nil || p.Name != "导入的歌单" {
|
||||
t.Errorf("文件里的名字 %q:%#v, %v,期望改用“导入的歌单”", name, p, err)
|
||||
}
|
||||
}
|
||||
if p, _, err := f.svc.ImportFile(t.Context(), alice.ID, " 通勤 ", song); err != nil || p.Name != "通勤" {
|
||||
t.Errorf("合法的名字 = %#v, %v,期望去掉首尾空白后照用", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportFileIsNotRateLimitedButRespectsPlaylistLimit(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
f.quota.exhausted = true
|
||||
for i := range 49 {
|
||||
if _, _, err := f.svc.ImportFile(t.Context(), alice.ID, "导入"+strconv.Itoa(i), nil); err != nil {
|
||||
t.Fatalf("第 %d 次导入 = %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if len(f.quota.keys) != 0 {
|
||||
t.Errorf("导入文件不访问外部,不应取额度:%v", f.quota.keys)
|
||||
}
|
||||
if _, _, err := f.svc.ImportFile(t.Context(), alice.ID, "第 51 个", nil); !errors.Is(err, ErrTooManyPlaylists) {
|
||||
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLink(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
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"
|
||||
linkQuota := "playlist-link:member:" + alice.ID
|
||||
cases := []struct {
|
||||
raw string
|
||||
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://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},
|
||||
}
|
||||
for _, c := range cases {
|
||||
before := len(f.quota.keys)
|
||||
source, id, err := f.svc.ResolveLink(t.Context(), alice.ID, c.raw)
|
||||
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 taken := f.quota.keys[before:]; !slices.Equal(taken, c.quota) {
|
||||
t.Errorf("ResolveLink(%q) 取的额度 = %v,期望 %v:长链接在本地识别,只有解析短链接才取链接额度", c.raw, taken, c.quota)
|
||||
}
|
||||
}
|
||||
if f.quota.burst != 10 || f.quota.refill != 6*time.Minute {
|
||||
t.Errorf("链接额度是突发 %d 次、每 %v 恢复 1 次,期望突发 10 次、每 6 分钟恢复 1 次", f.quota.burst, f.quota.refill)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPlatform(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
sevenMeta := track.Meta{Title: "七里香", Artist: "周杰伦", PageURL: "https://music.163.com/#/song?id=185811", Duration: 299 * time.Second}
|
||||
f.netease.Add("185811", sevenMeta, "", nil)
|
||||
f.netease.AddPlaylist(port.Playlist{ID: "p1", Name: "周杰伦"}, "185811", sunny.ID, "185811")
|
||||
|
||||
p, n, err := f.svc.ImportPlatform(t.Context(), alice.ID, " 周杰伦 ", track.NetEase, "p1")
|
||||
if err != nil || n != 2 || p.Name != "周杰伦" || p.TrackCount != 2 {
|
||||
t.Fatalf("ImportPlatform = %#v, %d, %v,期望去掉重复的、导入 2 首", p, n, err)
|
||||
}
|
||||
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
|
||||
want := []port.PlaylistItem{{Track: netease("185811"), Meta: sevenMeta}, {Track: sunny, Meta: sunnyMeta}}
|
||||
if err != nil || !slices.Equal(items, want) {
|
||||
t.Errorf("导入的曲目 = %#v, %v\n期望与平台同序、用音源的元数据 %#v", items, err, want)
|
||||
}
|
||||
if want := []string{"playlist-import:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) || f.quota.burst != 10 || f.quota.refill != 6*time.Minute {
|
||||
t.Errorf("额度 = %v(突发 %d,每 %v 恢复 1 次),期望 %v(突发 10,每 6 分钟恢复 1 次)", f.quota.keys, f.quota.burst, f.quota.refill, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPlatformTruncatesThenDedupes(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
ids := []string{"0"}
|
||||
for i := range 1001 {
|
||||
id := strconv.Itoa(i)
|
||||
f.netease.Add(id, track.Meta{Title: "歌" + id}, "", nil)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
f.netease.AddPlaylist(port.Playlist{ID: "big"}, ids...)
|
||||
p, n, err := f.svc.ImportPlatform(t.Context(), alice.ID, "大歌单", track.NetEase, "big")
|
||||
if err != nil || n != 999 {
|
||||
t.Fatalf("ImportPlatform = %#v, %d, %v,期望前 1000 首去掉 1 首重复的,导入 999 首", p, n, err)
|
||||
}
|
||||
items, err := f.svc.Items(t.Context(), alice.ID, p.ID)
|
||||
if err != nil || items[0].Track != netease("0") || items[len(items)-1].Track != netease("998") {
|
||||
t.Errorf("导入的曲目 = %d 首, %v,期望从 0 到 998", len(items), err)
|
||||
}
|
||||
}
|
||||
|
||||
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, " ", track.NetEase, "p1"); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("空的歌单名 = %v,期望 ErrInvalidName", err)
|
||||
}
|
||||
if len(f.quota.keys) != 0 {
|
||||
t.Errorf("被拒绝的请求不应取额度:%v", f.quota.keys)
|
||||
}
|
||||
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "不存在", track.NetEase, "missing"); !errors.Is(err, port.ErrProviderFailed) {
|
||||
t.Errorf("音源里没有的歌单 = %v,期望 ErrProviderFailed", err)
|
||||
}
|
||||
for i := range 49 {
|
||||
if _, err := f.svc.Create(t.Context(), alice.ID, "歌单"+strconv.Itoa(i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "第 51 个", track.NetEase, "p1"); !errors.Is(err, ErrTooManyPlaylists) {
|
||||
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformImportsAreRateLimited(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
f.netease.AddPlaylist(port.Playlist{ID: "p1"}, sunny.ID)
|
||||
f.links["https://163cn.tv/abc"] = "https://music.163.com/playlist?id=1"
|
||||
f.quota.exhausted = true
|
||||
if source, id, err := f.svc.ResolveLink(t.Context(), alice.ID, "https://music.163.com/playlist?id=1"); err != nil || source != track.NetEase || id != "1" {
|
||||
t.Errorf("额度用完时解析长链接 = %q, %q, %v,期望照常识别", source, id, err)
|
||||
}
|
||||
if _, _, err := f.svc.ResolveLink(t.Context(), alice.ID, "https://163cn.tv/abc"); !errors.Is(err, ErrTooFast) {
|
||||
t.Errorf("额度用完时解析短链接 = %v,期望 ErrTooFast", err)
|
||||
}
|
||||
if _, _, err := f.svc.ImportPlatform(t.Context(), alice.ID, "周杰伦", track.NetEase, "p1"); !errors.Is(err, ErrTooFast) {
|
||||
t.Errorf("额度用完时导入 = %v,期望 ErrTooFast", err)
|
||||
}
|
||||
if want := []string{"playlist-link:member:" + alice.ID, "playlist-import:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) {
|
||||
t.Errorf("取的额度 = %v,期望短链接取链接额度、导入取导入额度 %v", f.quota.keys, want)
|
||||
}
|
||||
if lists, _ := f.svc.List(t.Context(), alice.ID); len(lists) != 1 {
|
||||
t.Errorf("额度用完时不应建歌单:%#v", lists)
|
||||
}
|
||||
}
|
||||
@@ -32,15 +32,16 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
playlists port.Playlists
|
||||
members port.Members
|
||||
tracks port.Tracks
|
||||
catalog *catalog.Service
|
||||
quota port.AccountQuota
|
||||
playlists port.Playlists
|
||||
members port.Members
|
||||
tracks port.TrackLookup
|
||||
catalog *catalog.Service
|
||||
quota port.AccountQuota
|
||||
shortLinks port.ShortLinks
|
||||
}
|
||||
|
||||
func New(playlists port.Playlists, members port.Members, tracks port.Tracks, catalog *catalog.Service, quota port.AccountQuota) *Service {
|
||||
return &Service{playlists: playlists, members: members, tracks: tracks, catalog: catalog, quota: quota}
|
||||
func New(playlists port.Playlists, members port.Members, tracks port.TrackLookup, catalog *catalog.Service, quota port.AccountQuota, shortLinks port.ShortLinks) *Service {
|
||||
return &Service{playlists: playlists, members: members, tracks: tracks, catalog: catalog, quota: quota, shortLinks: shortLinks}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, memberID string) ([]port.PlaylistRecord, error) {
|
||||
@@ -95,13 +96,9 @@ func (s *Service) Add(ctx context.Context, memberID, id string, t track.Track) (
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
wait, err := s.quota.Take(ctx, "playlist:member:"+memberID, addBurst, addRefill)
|
||||
if err != nil {
|
||||
if err := s.take(ctx, "playlist:member:"+memberID, addBurst, addRefill); err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
if wait > 0 {
|
||||
return port.PlaylistItem{}, ErrTooFast
|
||||
}
|
||||
meta, err := s.meta(ctx, t)
|
||||
if err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
@@ -160,6 +157,17 @@ func (s *Service) registered(ctx context.Context, memberID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) take(ctx context.Context, key string, burst int, refill time.Duration) error {
|
||||
wait, err := s.quota.Take(ctx, key, burst, refill)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wait > 0 {
|
||||
return ErrTooFast
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 链接曲目准备好之前没有元数据,音源也查不到。
|
||||
func (s *Service) meta(ctx context.Context, t track.Track) (track.Meta, error) {
|
||||
if t.Source != track.Link {
|
||||
|
||||
@@ -31,6 +31,16 @@ func (t tracksTable) PreparedTrack(_ context.Context, k track.Track) (port.Prepa
|
||||
return p, ok, nil
|
||||
}
|
||||
|
||||
func (t tracksTable) TrackMetas(_ context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
|
||||
found := map[track.Track]track.Meta{}
|
||||
for _, k := range ts {
|
||||
if p, ok := t[k]; ok {
|
||||
found[k] = p.Meta
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (tracksTable) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
|
||||
return nil
|
||||
}
|
||||
@@ -54,21 +64,37 @@ func (roomQuota) TakePick(context.Context, string, int, time.Duration) (time.Dur
|
||||
type accountQuota struct {
|
||||
exhausted bool
|
||||
keys []string
|
||||
// 最近一次取额度时给的额度。
|
||||
burst int
|
||||
refill time.Duration
|
||||
}
|
||||
|
||||
func (q *accountQuota) Take(_ context.Context, key string, _ int, _ time.Duration) (time.Duration, error) {
|
||||
func (q *accountQuota) Take(_ context.Context, key string, burst int, refill time.Duration) (time.Duration, error) {
|
||||
q.keys = append(q.keys, key)
|
||||
q.burst, q.refill = burst, refill
|
||||
if q.exhausted {
|
||||
return time.Second, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 短链接到解析结果;不在表里的短链接解析失败。
|
||||
type shortLinks map[string]string
|
||||
|
||||
func (l shortLinks) Resolve(_ context.Context, link string) (string, error) {
|
||||
if final, ok := l[link]; ok {
|
||||
return final, nil
|
||||
}
|
||||
return "", errors.New("跳转到了不受信任的主机")
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
svc *Service
|
||||
store *porttest.Store
|
||||
tracks tracksTable
|
||||
quota *accountQuota
|
||||
svc *Service
|
||||
store *porttest.Store
|
||||
tracks tracksTable
|
||||
quota *accountQuota
|
||||
netease *porttest.Source
|
||||
links shortLinks
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) fixture {
|
||||
@@ -80,7 +106,8 @@ func newFixture(t *testing.T) fixture {
|
||||
tracks := tracksTable{}
|
||||
music := catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, tracks, noHistory{}, store, roomQuota{})
|
||||
q := &accountQuota{}
|
||||
return fixture{svc: New(store, store, tracks, music, q), store: store, tracks: tracks, quota: q}
|
||||
links := shortLinks{}
|
||||
return fixture{svc: New(store, store, tracks, music, q, links), store: store, tracks: tracks, quota: q, netease: netease, links: links}
|
||||
}
|
||||
|
||||
func (f fixture) member(t *testing.T, username string) member.Member {
|
||||
@@ -131,6 +158,15 @@ func TestGuestCannotUsePlaylists(t *testing.T) {
|
||||
if err := f.svc.Move(t.Context(), guest.ID, "any", []track.Track{sunny}, nil); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客调整顺序 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, _, err := f.svc.ImportFile(t.Context(), guest.ID, "导入", []ImportedTrack{{Track: sunny, Title: "晴天"}}); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客导入文件 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, _, err := f.svc.ResolveLink(t.Context(), guest.ID, "https://music.163.com/playlist?id=1"); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客解析歌单链接 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, _, err := f.svc.ImportPlatform(t.Context(), guest.ID, "导入", track.NetEase, "1"); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客导入平台歌单 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if len(f.quota.keys) != 0 {
|
||||
t.Errorf("游客不应消耗额度:%v", f.quota.keys)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
|
||||
services := api.Services{
|
||||
Identity: identity.New(store, store, store, gateway),
|
||||
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), gateway, base.With("component", "accounts")),
|
||||
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb)),
|
||||
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb), nil),
|
||||
Lobby: rooms,
|
||||
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
|
||||
Gateway: gateway,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package track
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -40,6 +41,14 @@ func asciiLower(s string) string {
|
||||
}, s)
|
||||
}
|
||||
|
||||
func NetEasePageURL(id string) string {
|
||||
return "https://music.163.com/#/song?id=" + url.QueryEscape(id)
|
||||
}
|
||||
|
||||
func QQPageURL(id string) string {
|
||||
return "https://y.qq.com/n/ryqq/songDetail/" + url.PathEscape(id)
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
Title string
|
||||
Artist string
|
||||
|
||||
@@ -18,3 +18,17 @@ func TestLinkID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformPageURL(t *testing.T) {
|
||||
tests := []struct{ got, want string }{
|
||||
{NetEasePageURL("186016"), "https://music.163.com/#/song?id=186016"},
|
||||
{QQPageURL("0039MnYb0qxYhV"), "https://y.qq.com/n/ryqq/songDetail/0039MnYb0qxYhV"},
|
||||
{NetEasePageURL("1&id=2#x"), "https://music.163.com/#/song?id=1%26id%3D2%23x"},
|
||||
{QQPageURL("a/../b?c"), "https://y.qq.com/n/ryqq/songDetail/a%2F..%2Fb%3Fc"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("页面链接 = %q,期望 %q", tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user