From 2bf241ae3ce252274d8ff6e0c1c0ae6626628542 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Tue, 10 Nov 2026 09:53:38 +0800 Subject: [PATCH] feat: import playlists from files and from NetEase links --- internal/adapter/in/api/api_test.go | 2 +- internal/adapter/in/api/catalog_test.go | 4 + internal/app/catalog/catalog.go | 8 + internal/app/catalog/catalog_test.go | 20 ++ internal/app/playlist/import.go | 186 +++++++++++++++ internal/app/playlist/import_test.go | 300 ++++++++++++++++++++++++ internal/app/playlist/playlist.go | 32 ++- internal/app/playlist/playlist_test.go | 48 +++- internal/bootstrap/public.go | 2 +- internal/domain/track/track.go | 9 + internal/domain/track/track_test.go | 14 ++ 11 files changed, 605 insertions(+), 20 deletions(-) create mode 100644 internal/app/playlist/import.go create mode 100644 internal/app/playlist/import_test.go diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go index 5483499..9e2047a 100644 --- a/internal/adapter/in/api/api_test.go +++ b/internal/adapter/in/api/api_test.go @@ -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 { diff --git a/internal/adapter/in/api/catalog_test.go b/internal/adapter/in/api/catalog_test.go index 4ab9bfb..a598d1c 100644 --- a/internal/adapter/in/api/catalog_test.go +++ b/internal/adapter/in/api/catalog_test.go @@ -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 } diff --git a/internal/app/catalog/catalog.go b/internal/app/catalog/catalog.go index f605f16..eaa985b 100644 --- a/internal/app/catalog/catalog.go +++ b/internal/app/catalog/catalog.go @@ -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) diff --git a/internal/app/catalog/catalog_test.go b/internal/app/catalog/catalog_test.go index 862bde3..c1f8149 100644 --- a/internal/app/catalog/catalog_test.go +++ b/internal/app/catalog/catalog_test.go @@ -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"} diff --git a/internal/app/playlist/import.go b/internal/app/playlist/import.go new file mode 100644 index 0000000..cf986a9 --- /dev/null +++ b/internal/app/playlist/import.go @@ -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 } +} diff --git a/internal/app/playlist/import_test.go b/internal/app/playlist/import_test.go new file mode 100644 index 0000000..a2881da --- /dev/null +++ b/internal/app/playlist/import_test.go @@ -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) + } +} diff --git a/internal/app/playlist/playlist.go b/internal/app/playlist/playlist.go index 50043c5..b10ae35 100644 --- a/internal/app/playlist/playlist.go +++ b/internal/app/playlist/playlist.go @@ -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 { diff --git a/internal/app/playlist/playlist_test.go b/internal/app/playlist/playlist_test.go index e9614d5..1998ddb 100644 --- a/internal/app/playlist/playlist_test.go +++ b/internal/app/playlist/playlist_test.go @@ -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) } diff --git a/internal/bootstrap/public.go b/internal/bootstrap/public.go index 0f0ad4d..34fe137 100644 --- a/internal/bootstrap/public.go +++ b/internal/bootstrap/public.go @@ -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, diff --git a/internal/domain/track/track.go b/internal/domain/track/track.go index af1ae3f..96fc8c9 100644 --- a/internal/domain/track/track.go +++ b/internal/domain/track/track.go @@ -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 diff --git a/internal/domain/track/track_test.go b/internal/domain/track/track_test.go index 5721d43..91b8b8d 100644 --- a/internal/domain/track/track_test.go +++ b/internal/domain/track/track_test.go @@ -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) + } + } +}