feat: manage member playlists with server-side track metadata

This commit is contained in:
DiheChen committed 2026-11-09 21:26:41 +08:00
1 parent ffbf28c165
commit 95a56267a4
2 files changed
+398

No files matched your search

+164
View File
@@ -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)
}
+234
View File
@@ -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)
}
}