feat: manage member playlists with server-side track metadata
This commit is contained in:
1 parent
ffbf28c165
commit
95a56267a4
2 files changed
+398
No files matched your search
@@ -0,0 +1,164 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/catalog"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPlaylists = 50
|
||||
maxItems = 1000
|
||||
maxNameLength = 50
|
||||
// 收藏可能要向音源查询元数据:额度防止批量请求让共用的音源账号被平台限流。
|
||||
addBurst = 30
|
||||
addRefill = 2 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotRegistered = errors.New("注册账号之后才能使用歌单")
|
||||
ErrInvalidName = errors.New("name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符")
|
||||
ErrTooManyPlaylists = errors.New("最多只能建 50 个歌单")
|
||||
ErrPlaylistFull = errors.New("一个歌单最多 1000 首歌")
|
||||
ErrTrackNotReady = errors.New("这首歌还在准备,准备好之后再保存")
|
||||
ErrTooFast = errors.New("操作太频繁,请稍后再试。")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
playlists port.Playlists
|
||||
members port.Members
|
||||
tracks port.Tracks
|
||||
catalog *catalog.Service
|
||||
quota port.AccountQuota
|
||||
}
|
||||
|
||||
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 (s *Service) List(ctx context.Context, memberID string) ([]port.PlaylistRecord, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.playlists.MemberPlaylists(ctx, memberID)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, memberID, name string) (port.PlaylistRecord, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
name, err := validName(name)
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
p, err := s.playlists.CreatePlaylist(ctx, memberID, name, maxPlaylists)
|
||||
if errors.Is(err, port.ErrPlaylistLimit) {
|
||||
return port.PlaylistRecord{}, ErrTooManyPlaylists
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Service) Rename(ctx context.Context, memberID, id, name string) error {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := validName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.playlists.RenamePlaylist(ctx, memberID, id, name)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, memberID, id string) error {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.playlists.DeletePlaylist(ctx, memberID, id)
|
||||
}
|
||||
|
||||
func (s *Service) Items(ctx context.Context, memberID, id string) ([]port.PlaylistItem, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.playlists.PlaylistItems(ctx, memberID, id)
|
||||
}
|
||||
|
||||
// 链接曲目的 t.ID 必须已经按 track.LinkID 规范化。
|
||||
func (s *Service) Add(ctx context.Context, memberID, id string, t track.Track) (port.PlaylistItem, error) {
|
||||
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 {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
if wait > 0 {
|
||||
return port.PlaylistItem{}, ErrTooFast
|
||||
}
|
||||
meta, err := s.meta(ctx, t)
|
||||
if err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
item := port.PlaylistItem{Track: t, Meta: meta}
|
||||
err = s.playlists.AddPlaylistItem(ctx, memberID, id, item, maxItems)
|
||||
if errors.Is(err, port.ErrPlaylistLimit) {
|
||||
return port.PlaylistItem{}, ErrPlaylistFull
|
||||
}
|
||||
if err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) Remove(ctx context.Context, memberID, id string, t track.Track) error {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.playlists.RemovePlaylistItem(ctx, memberID, id, t)
|
||||
}
|
||||
|
||||
func (s *Service) registered(ctx context.Context, memberID string) error {
|
||||
profile, err := s.members.AccountProfile(ctx, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profile.Username == "" {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 链接曲目准备好之前没有元数据,音源也查不到。
|
||||
func (s *Service) meta(ctx context.Context, t track.Track) (track.Meta, error) {
|
||||
if t.Source != track.Link {
|
||||
return s.catalog.TrackMeta(ctx, t)
|
||||
}
|
||||
p, ok, err := s.tracks.PreparedTrack(ctx, t)
|
||||
if err != nil {
|
||||
return track.Meta{}, err
|
||||
}
|
||||
if !ok {
|
||||
return track.Meta{}, ErrTrackNotReady
|
||||
}
|
||||
return p.Meta, nil
|
||||
}
|
||||
|
||||
func validName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if utf8.RuneCountInString(name) > maxNameLength || strings.ContainsFunc(name, unicode.IsControl) || !strings.ContainsFunc(name, visible) {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// unicode.IsGraphic 把 Zs 类的空白也算作 graphic。
|
||||
func visible(r rune) bool {
|
||||
return unicode.IsGraphic(r) && !unicode.IsSpace(r)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/catalog"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/port/porttest"
|
||||
"github.com/dihemoe/alisten/internal/domain/member"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var (
|
||||
sunny = track.Track{Source: track.NetEase, ID: "186016"}
|
||||
sunnyMeta = track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second}
|
||||
clip = track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
|
||||
)
|
||||
|
||||
type tracksTable map[track.Track]port.PreparedTrack
|
||||
|
||||
func (t tracksTable) PreparedTrack(_ context.Context, k track.Track) (port.PreparedTrack, bool, error) {
|
||||
p, ok := t[k]
|
||||
return p, ok, nil
|
||||
}
|
||||
|
||||
func (tracksTable) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type noHistory struct{}
|
||||
|
||||
func (noHistory) AutoDJSeeds(context.Context, string, bool) ([]track.Track, error) { return nil, nil }
|
||||
|
||||
func (noHistory) AutoDJExclusions(context.Context, string) ([]track.Track, error) { return nil, nil }
|
||||
|
||||
type roomQuota struct{}
|
||||
|
||||
func (roomQuota) TakeSearch(context.Context, string, int, time.Duration) (time.Duration, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (roomQuota) TakePick(context.Context, string, int, time.Duration) (time.Duration, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type accountQuota struct {
|
||||
exhausted bool
|
||||
keys []string
|
||||
}
|
||||
|
||||
func (q *accountQuota) Take(_ context.Context, key string, _ int, _ time.Duration) (time.Duration, error) {
|
||||
q.keys = append(q.keys, key)
|
||||
if q.exhausted {
|
||||
return time.Second, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
svc *Service
|
||||
store *porttest.Store
|
||||
tracks tracksTable
|
||||
quota *accountQuota
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) fixture {
|
||||
t.Helper()
|
||||
store := porttest.New()
|
||||
netease := porttest.NewSource(track.NetEase)
|
||||
netease.Add(sunny.ID, sunnyMeta, "", nil)
|
||||
bilibili := porttest.NewBilibili()
|
||||
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}
|
||||
}
|
||||
|
||||
func (f fixture) member(t *testing.T, username string) member.Member {
|
||||
t.Helper()
|
||||
hash := sha256.Sum256([]byte(username))
|
||||
m, err := f.store.CreateMember(t.Context(), hash[:], "游客-"+username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if username != "" {
|
||||
if err := f.store.Register(t.Context(), m.ID, username, "hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (f fixture) favorites(t *testing.T, memberID string) string {
|
||||
t.Helper()
|
||||
lists, err := f.svc.List(t.Context(), memberID)
|
||||
if err != nil || len(lists) == 0 || !lists[0].Favorites {
|
||||
t.Fatalf("歌单 = %#v, %v", lists, err)
|
||||
}
|
||||
return lists[0].ID
|
||||
}
|
||||
|
||||
func TestGuestCannotUsePlaylists(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
guest := f.member(t, "")
|
||||
if _, err := f.svc.List(t.Context(), guest.ID); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客读取歌单 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, err := f.svc.Create(t.Context(), guest.ID, "通勤"); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客新建歌单 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, err := f.svc.Add(t.Context(), guest.ID, "any", sunny); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客收藏 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if len(f.quota.keys) != 0 {
|
||||
t.Errorf("游客不应消耗额度:%v", f.quota.keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidatesName(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
p, err := f.svc.Create(t.Context(), alice.ID, " 通勤 ")
|
||||
if err != nil || p.Name != "通勤" {
|
||||
t.Fatalf("Create = %#v, %v,期望去掉首尾空白", p, err)
|
||||
}
|
||||
for _, name := range []string{"", " ", strings.Repeat("歌", 51), "通\n勤", "\u200b"} {
|
||||
if _, err := f.svc.Create(t.Context(), alice.ID, name); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("Create(%q) = %v,期望 ErrInvalidName", name, err)
|
||||
}
|
||||
if err := f.svc.Rename(t.Context(), alice.ID, p.ID, name); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("Rename(%q) = %v,期望 ErrInvalidName", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := f.svc.Create(t.Context(), alice.ID, strings.Repeat("歌", 50)); err != nil {
|
||||
t.Errorf("50 个字的歌单名 = %v,期望可以", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStopsAtFiftyPlaylists(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
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.Create(t.Context(), alice.ID, "第 51 个"); !errors.Is(err, ErrTooManyPlaylists) {
|
||||
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTakesMetaFromTheServer(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
item, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny)
|
||||
if want := (port.PlaylistItem{Track: sunny, Meta: sunnyMeta}); err != nil || item != want {
|
||||
t.Fatalf("Add = %#v, %v,期望 %#v", item, err, want)
|
||||
}
|
||||
if items, err := f.svc.Items(t.Context(), alice.ID, favorites); err != nil || !slices.Equal(items, []port.PlaylistItem{item}) {
|
||||
t.Errorf("Items = %#v, %v", items, err)
|
||||
}
|
||||
if want := []string{"playlist:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) {
|
||||
t.Errorf("取的额度 = %v,期望 %v", f.quota.keys, want)
|
||||
}
|
||||
if err := f.svc.Remove(t.Context(), alice.ID, favorites, sunny); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items, _ := f.svc.Items(t.Context(), alice.ID, favorites); len(items) != 0 {
|
||||
t.Errorf("移除之后 = %#v,期望为空", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddLinkNeedsPreparedTrack(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, clip); !errors.Is(err, ErrTrackNotReady) {
|
||||
t.Errorf("没准备过的链接 = %v,期望 ErrTrackNotReady", err)
|
||||
}
|
||||
meta := track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: clip.ID, Duration: time.Minute}
|
||||
f.tracks[clip] = port.PreparedTrack{Meta: meta}
|
||||
if item, err := f.svc.Add(t.Context(), alice.ID, favorites, clip); err != nil || item.Meta != meta {
|
||||
t.Errorf("准备过的链接 = %#v, %v,期望用曲目表里的元数据", item, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddIsRateLimited(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
f.quota.exhausted = true
|
||||
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); !errors.Is(err, ErrTooFast) {
|
||||
t.Errorf("额度用完时 = %v,期望 ErrTooFast", err)
|
||||
}
|
||||
if items, _ := f.svc.Items(t.Context(), alice.ID, favorites); len(items) != 0 {
|
||||
t.Errorf("额度用完时不应加入:%#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
type fullPlaylists struct{ *porttest.Store }
|
||||
|
||||
func (fullPlaylists) AddPlaylistItem(context.Context, string, string, port.PlaylistItem, int) error {
|
||||
return port.ErrPlaylistLimit
|
||||
}
|
||||
|
||||
func TestAddReportsFullPlaylist(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
f.svc.playlists = fullPlaylists{f.store}
|
||||
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); !errors.Is(err, ErrPlaylistFull) {
|
||||
t.Errorf("歌单满了时 = %v,期望 ErrPlaylistFull", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoritesStayFixed(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
if err := f.svc.Rename(t.Context(), alice.ID, favorites, "别的"); !errors.Is(err, port.ErrFavoritesFixed) {
|
||||
t.Errorf("“我喜欢”改名 = %v,期望 ErrFavoritesFixed", err)
|
||||
}
|
||||
if err := f.svc.Delete(t.Context(), alice.ID, favorites); !errors.Is(err, port.ErrFavoritesFixed) {
|
||||
t.Errorf("删除“我喜欢” = %v,期望 ErrFavoritesFixed", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user