feat: store playlist share links and copy shared playlists

This commit is contained in:
DiheChen committed 2026-11-11 00:07:07 +08:00
1 parent 910e7339ad
commit 9f23e97652
8 files changed
+491 -4

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
-- +goose Up
-- 分享链接中的随机串,NULL 表示没有分享。明文保存:所有者随时可以再复制一次链接。
ALTER TABLE playlists ADD COLUMN share_id text UNIQUE;
+31 -1
View File
@@ -2,7 +2,7 @@
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'favorites', '我喜欢');
-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id
@@ -108,3 +108,33 @@ SELECT p.id FROM playlists p
JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id
ORDER BY p.id;
-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = @share_id WHERE id = @id AND owner_id = @owner_id;
-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = sqlc.arg(share_id)::text;
-- name: CopySharedPlaylistItems :one
-- 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
-- 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
WITH shared AS (
SELECT id FROM playlists WHERE share_id = sqlc.arg(share_id)::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT sqlc.arg(to_id), i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied;
-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = @playlist_id AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1;
@@ -53,6 +53,39 @@ func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsPa
return result.RowsAffected(), nil
}
const copySharedPlaylistItems = `-- name: CopySharedPlaylistItems :one
WITH shared AS (
SELECT id FROM playlists WHERE share_id = $1::text
), copied AS (
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
SELECT $2, i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
row_number() OVER (ORDER BY i.position)
FROM playlist_items i
JOIN shared s ON s.id = i.playlist_id
RETURNING 1
)
SELECT EXISTS (SELECT FROM shared) AS found, (SELECT count(*) FROM copied)::bigint AS copied
`
type CopySharedPlaylistItemsParams struct {
ShareID string
ToID string
}
type CopySharedPlaylistItemsRow struct {
Found bool
Copied int64
}
// 确认分享还在和复制曲目在同一条语句里,看到的是同一个快照:要么完整复制,要么读不到分享。不锁来源歌单:两个人同时复制
// 对方分享的“我喜欢”时,各自已经锁住了自己的“我喜欢”,再去锁对方的就会互相等待。
func (q *Queries) CopySharedPlaylistItems(ctx context.Context, arg CopySharedPlaylistItemsParams) (CopySharedPlaylistItemsRow, error) {
row := q.db.QueryRow(ctx, copySharedPlaylistItems, arg.ShareID, arg.ToID)
var i CopySharedPlaylistItemsRow
err := row.Scan(&i.Found, &i.Copied)
return i, err
}
const countPlaylistItems = `-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = $1
`
@@ -258,7 +291,7 @@ func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]M
}
const memberPlaylists = `-- name: MemberPlaylists :many
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, p.share_id, count(i.track_id)::bigint AS track_count
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1
@@ -272,6 +305,7 @@ type MemberPlaylistsRow struct {
Name string
CreatedAt time.Time
UpdatedAt time.Time
ShareID *string
TrackCount int64
}
@@ -290,6 +324,7 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.ShareID,
&i.TrackCount,
); err != nil {
return nil, err
@@ -302,6 +337,25 @@ func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]Member
return items, nil
}
const playlistCover = `-- name: PlaylistCover :one
SELECT cover_url, cover_key FROM playlist_items
WHERE playlist_id = $1 AND (cover_url IS NOT NULL OR cover_key IS NOT NULL)
ORDER BY position DESC
LIMIT 1
`
type PlaylistCoverRow struct {
CoverURL *string
CoverKey *string
}
func (q *Queries) PlaylistCover(ctx context.Context, playlistID string) (PlaylistCoverRow, error) {
row := q.db.QueryRow(ctx, playlistCover, playlistID)
var i PlaylistCoverRow
err := row.Scan(&i.CoverURL, &i.CoverKey)
return i, err
}
const playlistHasItem = `-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3)
`
@@ -458,6 +512,52 @@ func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams)
return err
}
const setPlaylistShare = `-- name: SetPlaylistShare :execrows
UPDATE playlists SET share_id = $1 WHERE id = $2 AND owner_id = $3
`
type SetPlaylistShareParams struct {
ShareID *string
ID string
OwnerID string
}
func (q *Queries) SetPlaylistShare(ctx context.Context, arg SetPlaylistShareParams) (int64, error) {
result, err := q.db.Exec(ctx, setPlaylistShare, arg.ShareID, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const sharedPlaylist = `-- name: SharedPlaylist :one
SELECT p.id, p.kind, p.name, m.name AS owner_name, m.avatar_hash AS owner_avatar_hash
FROM playlists p
JOIN members m ON m.id = p.owner_id
WHERE p.share_id = $1::text
`
type SharedPlaylistRow struct {
ID string
Kind string
Name string
OwnerName string
OwnerAvatarHash *string
}
func (q *Queries) SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylistRow, error) {
row := q.db.QueryRow(ctx, sharedPlaylist, shareID)
var i SharedPlaylistRow
err := row.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.OwnerName,
&i.OwnerAvatarHash,
)
return i, err
}
const touchPlaylist = `-- name: TouchPlaylist :exec
UPDATE playlists SET updated_at = now() WHERE id = $1
`
+86 -1
View File
@@ -42,6 +42,7 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Cover: covers[r.ID],
ShareID: orEmpty(r.ShareID),
}
}
return lists, nil
@@ -180,6 +181,10 @@ func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.P
if err != nil {
return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err)
}
return playlistItems(rows), nil
}
func playlistItems(rows []query.PlaylistItem) []port.PlaylistItem {
items := make([]port.PlaylistItem, len(rows))
for i, r := range rows {
items[i] = port.PlaylistItem{
@@ -195,7 +200,7 @@ func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.P
},
}
}
return items, nil
return items
}
func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
@@ -355,6 +360,86 @@ func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, track
return nil
}
func (s *Store) SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.SetPlaylistShare(ctx, query.SetPlaylistShareParams{ID: id, OwnerID: ownerID, ShareID: nullable(shareID)})
if err == nil && n == 0 {
err = port.ErrPlaylistNotFound
}
if err != nil {
return fmt.Errorf("设置歌单 %s 的分享:%w", id, err)
}
return nil
}
func (s *Store) SharedPlaylist(ctx context.Context, shareID string) (port.SharedPlaylist, error) {
var shared port.SharedPlaylist
// 歌单和曲目在同一个快照里读:分开读时,删除歌单可能恰好发生在两次读取之间,读到歌单还在、曲目却已经没了。
err := pgx.BeginTxFunc(ctx, s.pool, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
p, err := q.SharedPlaylist(ctx, shareID)
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrShareNotFound
}
if err != nil {
return err
}
rows, err := q.PlaylistItems(ctx, p.ID)
if err != nil {
return err
}
shared = port.SharedPlaylist{
Name: p.Name,
Favorites: p.Kind == favoritesKind,
OwnerName: p.OwnerName,
OwnerAvatarHash: orEmpty(p.OwnerAvatarHash),
Items: playlistItems(rows),
}
return nil
})
if err != nil {
return port.SharedPlaylist{}, fmt.Errorf("读取分享的歌单:%w", err)
}
return shared, nil
}
func (s *Store) CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
var record port.PlaylistRecord
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
created, err := createPlaylist(ctx, q, ownerID, name, limit)
if err != nil {
return err
}
copied, err := q.CopySharedPlaylistItems(ctx, query.CopySharedPlaylistItemsParams{ShareID: shareID, ToID: created.ID})
if err != nil {
return err
}
if !copied.Found {
return port.ErrShareNotFound
}
if err := q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: copied.Copied}); err != nil {
return err
}
record = port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: int(copied.Copied), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}
cover, err := q.PlaylistCover(ctx, created.ID)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
record.Cover = &port.Cover{URL: orEmpty(cover.CoverURL), Key: track.ObjectKey(orEmpty(cover.CoverKey))}
return nil
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 复制分享的歌单:%w", ownerID, err)
}
return record, nil
}
func splitTracks(tracks []track.Track) (sources, ids []string) {
sources, ids = make([]string, len(tracks)), make([]string, len(tracks))
for i, t := range tracks {
@@ -689,3 +689,159 @@ func TestConcurrentImportsRespectLimit(t *testing.T) {
t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
}
}
func TestSharePlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
hash := "0bc83cb571cd1c50ba6f3e8a78ef1346"
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, &hash, nil); err != nil {
t.Fatal(err)
}
favorites := favoritesOf(t, store, alice.ID)
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, clipItem, 1000); err != nil {
t.Fatal(err)
}
before := updatedAt(t, store, alice.ID, favorites)
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-1"); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "share-1" {
t.Errorf("歌单 = %#v, %v,期望带上分享 ID", lists, err)
}
if !updatedAt(t, store, alice.ID, favorites).Equal(before) {
t.Error("分享改变了歌单的更新时间")
}
shared, err := store.SharedPlaylist(t.Context(), "share-1")
want := port.SharedPlaylist{Name: "我喜欢", Favorites: true, OwnerName: alice.Name, OwnerAvatarHash: hash, Items: []port.PlaylistItem{clipItem, sunnyItem}}
if err != nil || shared.Name != want.Name || shared.Favorites != want.Favorites || shared.OwnerName != want.OwnerName ||
shared.OwnerAvatarHash != want.OwnerAvatarHash || !slices.Equal(shared.Items, want.Items) {
t.Errorf("分享的歌单 = %#v, %v,期望 %#v", shared, err, want)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, "share-2"); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("重新生成之后的旧链接 = %v,期望 ErrShareNotFound", err)
}
if shared, err := store.SharedPlaylist(t.Context(), "share-2"); err != nil || len(shared.Items) != 2 {
t.Errorf("新链接 = %#v, %v", shared, err)
}
for _, id := range []string{favorites, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, "share-3"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, id, ""); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("停止分享别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
for range 2 {
if err := store.SetPlaylistShare(t.Context(), alice.ID, favorites, ""); err != nil {
t.Fatalf("停止分享 = %v,期望重复停止也成功", err)
}
}
if _, err := store.SharedPlaylist(t.Context(), "share-2"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("停止分享之后 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || lists[0].ShareID != "" {
t.Errorf("停止分享之后的歌单 = %#v, %v,期望没有分享 ID", lists, err)
}
}
func TestShareIDIsUnique(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
if err := store.SetPlaylistShare(t.Context(), alice.ID, favoritesOf(t, store, alice.ID), "share-1"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), bob.ID, favoritesOf(t, store, bob.ID), "share-1"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if shared, err := store.SharedPlaylist(t.Context(), "share-1"); err != nil || shared.OwnerName != alice.Name {
t.Errorf("分享 = %#v, %v,期望仍是第一个歌单", shared, err)
}
}
func TestDeletedPlaylistIsNoLongerShared(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
list := filled(t, store, alice.ID, "通勤", "1")
if err := store.SetPlaylistShare(t.Context(), alice.ID, list, "share-1"); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, list); err != nil {
t.Fatal(err)
}
if _, err := store.SharedPlaylist(t.Context(), "share-1"); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("删除歌单之后 = %v,期望 ErrShareNotFound", err)
}
}
func TestCopySharedPlaylist(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
source := filled(t, store, alice.ID, "通勤", "1", "2", "3")
if err := store.MovePlaylistItems(t.Context(), alice.ID, source, trackList("1"), &trackList("3")[0]); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), alice.ID, source, "share-1"); err != nil {
t.Fatal(err)
}
original := displayIDs(t, store, alice.ID, source)
copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "抄来的", 50)
if err != nil {
t.Fatal(err)
}
if want := (port.Cover{URL: songItem("1").Meta.CoverURL}); copied.Name != "抄来的" || copied.Favorites || copied.TrackCount != 3 ||
copied.ShareID != "" || copied.Cover == nil || *copied.Cover != want {
t.Errorf("复制返回 %#v,期望 3 首、封面是排在最前的 %+v、没有分享", copied, want)
}
lists, err := store.MemberPlaylists(t.Context(), bob.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和复制的歌单", lists, err)
}
if read := lists[1]; read.ID != copied.ID || read.Name != copied.Name || read.TrackCount != 3 || read.ShareID != "" ||
!read.CreatedAt.Equal(copied.CreatedAt) || !read.UpdatedAt.Equal(copied.UpdatedAt) {
t.Errorf("读出的 %#v,期望与复制返回的 %#v 一致", read, copied)
}
items, err := store.PlaylistItems(t.Context(), bob.ID, copied.ID)
sourceItems, _ := store.PlaylistItems(t.Context(), alice.ID, source)
if err != nil || !slices.Equal(items, sourceItems) {
t.Errorf("复制的曲目 = %#v, %v,期望与原歌单相同 %#v", items, err, sourceItems)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, copied.ID, songItem("9"), 1000); err != nil {
t.Fatal(err)
}
if got := displayIDs(t, store, bob.ID, copied.ID); !slices.Equal(got, append([]string{"9"}, original...)) {
t.Errorf("复制之后再加入 = %v,期望新加入的排在最前、其余保持 %v", got, original)
}
if got := displayIDs(t, store, alice.ID, source); !slices.Equal(got, original) {
t.Errorf("原歌单 = %v,期望不变 %v", got, original)
}
empty := filled(t, store, alice.ID, "空的")
if err := store.SetPlaylistShare(t.Context(), alice.ID, empty, "share-2"); err != nil {
t.Fatal(err)
}
if copied, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-2", "空的", 50); err != nil || copied.TrackCount != 0 || copied.Cover != nil {
t.Errorf("复制空歌单 = %#v, %v,期望建出一个没有曲目的歌单", copied, err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-1", "超限", 3); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
}
if _, err := store.CopySharedPlaylist(t.Context(), bob.ID, "share-x", "失效", 50); !errors.Is(err, port.ErrShareNotFound) {
t.Errorf("读不到的分享 = %v,期望 ErrShareNotFound", err)
}
if lists, err := store.MemberPlaylists(t.Context(), bob.ID); err != nil || len(lists) != 3 {
t.Errorf("失败之后有 %d 个歌单, %v,期望没有多建", len(lists), err)
}
}
+20
View File
@@ -12,6 +12,7 @@ var (
ErrPlaylistNotFound = errors.New("歌单不存在")
ErrFavoritesFixed = errors.New("“我喜欢”不能重命名或删除")
ErrPlaylistLimit = errors.New("歌单或歌单里的歌已达上限")
ErrShareNotFound = errors.New("分享已失效")
)
type PlaylistRecord struct {
@@ -22,6 +23,8 @@ type PlaylistRecord struct {
CreatedAt time.Time
UpdatedAt time.Time
Cover *Cover
// 没有分享时为空。
ShareID string
}
// 链接曲目的封面在对象存储中,只有对象键。
@@ -35,6 +38,16 @@ type PlaylistItem struct {
Meta track.Meta
}
// 分享页上显示的内容:不带所有者的成员 ID 和用户名。
type SharedPlaylist struct {
Name string
Favorites bool
OwnerName string
OwnerAvatarHash string
// 新加入的在前。
Items []PlaylistItem
}
// 所有方法都按 ownerID 过滤:别人的歌单和不存在的歌单一样,返回 ErrPlaylistNotFound。“我喜欢”由 Members.Register 创建。
type Playlists interface {
// “我喜欢”在最前,其余按创建的先后。Cover 是歌单里排在最前、有封面的那首歌的封面,没有时为 nil。
@@ -62,4 +75,11 @@ type Playlists interface {
CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error)
// 按 playlist.Move 的规则调整顺序,错误原样返回。
MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error
// shareID 为空字符串时停止分享。shareID 已被其他歌单使用时返回错误。
SetPlaylistShare(ctx context.Context, ownerID, id, shareID string) error
// 没有歌单使用这个 shareID 时返回 ErrShareNotFound。
SharedPlaylist(ctx context.Context, shareID string) (SharedPlaylist, error)
// 新建 ownerID 的歌单,复制 shareID 对应歌单的全部曲目,顺序不变;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时
// 返回 ErrPlaylistLimit,没有歌单使用这个 shareID 时返回 ErrShareNotFound,两种情况都不新建歌单。
CopySharedPlaylist(ctx context.Context, ownerID, shareID, name string, limit int) (PlaylistRecord, error)
}
+69 -1
View File
@@ -553,6 +553,7 @@ type storedPlaylist struct {
favorites bool
items []port.PlaylistItem
created, updated time.Time
shareID string
}
func (p *storedPlaylist) cover() *port.Cover {
@@ -590,6 +591,7 @@ func (s *Store) MemberPlaylists(_ context.Context, ownerID string) ([]port.Playl
CreatedAt: p.created,
UpdatedAt: p.updated,
Cover: p.cover(),
ShareID: p.shareID,
}
if p.favorites {
lists = append([]port.PlaylistRecord{r}, lists...)
@@ -698,11 +700,16 @@ func (s *Store) PlaylistItems(_ context.Context, ownerID, id string) ([]port.Pla
if p == nil {
return nil, port.ErrPlaylistNotFound
}
return p.display(), nil
}
// 新加入的在前。
func (p *storedPlaylist) display() []port.PlaylistItem {
items := make([]port.PlaylistItem, 0, len(p.items))
for i := len(p.items) - 1; i >= 0; i-- {
items = append(items, p.items[i])
}
return items, nil
return items
}
func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
@@ -808,3 +815,64 @@ func (s *Store) MovePlaylistItems(_ context.Context, ownerID, id string, tracks
p.updated = time.Now()
return nil
}
func (s *Store) SetPlaylistShare(_ context.Context, ownerID, id, shareID string) error {
if err := checkText(shareID); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return port.ErrPlaylistNotFound
}
if other := s.shared(shareID); other != nil && other != p {
return fmt.Errorf("分享 ID %s 已被其他歌单使用:违反唯一约束", shareID)
}
p.shareID = shareID
return nil
}
// 调用方持有 s.mu。
func (s *Store) shared(shareID string) *storedPlaylist {
for _, p := range s.playlists {
if shareID != "" && p.shareID == shareID {
return p
}
}
return nil
}
func (s *Store) SharedPlaylist(_ context.Context, shareID string) (port.SharedPlaylist, error) {
if err := checkText(shareID); err != nil {
return port.SharedPlaylist{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.shared(shareID)
if p == nil {
return port.SharedPlaylist{}, port.ErrShareNotFound
}
owner := s.member(p.owner).member
return port.SharedPlaylist{Name: p.name, Favorites: p.favorites, OwnerName: owner.Name, OwnerAvatarHash: owner.AvatarHash, Items: p.display()}, nil
}
func (s *Store) CopySharedPlaylist(_ context.Context, ownerID, shareID, name string, limit int) (port.PlaylistRecord, error) {
if err := checkText(shareID, name); err != nil {
return port.PlaylistRecord{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
p, err := s.newPlaylist(ownerID, name, limit)
if err != nil {
return port.PlaylistRecord{}, err
}
from := s.shared(shareID)
if from == nil {
// 与 PostgreSQL 的事务一样先新建再复制,读不到分享时撤销新建的歌单。
s.playlists = slices.DeleteFunc(s.playlists, func(other *storedPlaylist) bool { return other == p })
return port.PlaylistRecord{}, port.ErrShareNotFound
}
p.items = slices.Clone(from.items)
return port.PlaylistRecord{ID: p.id, Name: name, TrackCount: len(p.items), CreatedAt: p.created, UpdatedAt: p.updated, Cover: p.cover()}, nil
}
+25
View File
@@ -87,3 +87,28 @@ func TestImportPlaylistRejectsWhatPostgreSQLRejects(t *testing.T) {
t.Errorf("失败之后的歌单 = %#v,期望一个也没有建", lists)
}
}
func TestSetPlaylistShareRejectsDuplicates(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
first, err := store.CreatePlaylist(t.Context(), m.ID, "一", 10)
if err != nil {
t.Fatal(err)
}
second, err := store.CreatePlaylist(t.Context(), m.ID, "二", 10)
if err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), m.ID, first.ID, "share"); err != nil {
t.Fatal(err)
}
if err := store.SetPlaylistShare(t.Context(), m.ID, second.ID, "share"); err == nil {
t.Error("同一个分享 ID 设给第二个歌单时应报错")
}
if err := store.SetPlaylistShare(t.Context(), m.ID, first.ID, "share"); err != nil {
t.Errorf("重新设成自己已有的分享 ID = %v,期望成功", err)
}
}