From ca6603a994d2f9ef61cf68eed99afe6ca2581657 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Mon, 9 Nov 2026 21:26:41 +0800 Subject: [PATCH] feat: manage member playlists with server-side track metadata --- internal/app/playlist/playlist.go | 164 +++++++++++++++++ internal/app/playlist/playlist_test.go | 234 +++++++++++++++++++++++++ 2 files changed, 398 insertions(+) create mode 100644 internal/app/playlist/playlist.go create mode 100644 internal/app/playlist/playlist_test.go diff --git a/internal/app/playlist/playlist.go b/internal/app/playlist/playlist.go new file mode 100644 index 0000000..cf76620 --- /dev/null +++ b/internal/app/playlist/playlist.go @@ -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) +} diff --git a/internal/app/playlist/playlist_test.go b/internal/app/playlist/playlist_test.go new file mode 100644 index 0000000..471454a --- /dev/null +++ b/internal/app/playlist/playlist_test.go @@ -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) + } +}