feat: look up prepared tracks in bulk and create a playlist with its songs at once
This commit is contained in:
1 parent
92a8e63240
commit
83f8dbf3a5
13 files changed
+391
-19
No files matched your search
@@ -50,6 +50,10 @@ SELECT count(*) FROM playlist_items WHERE playlist_id = @playlist_id;
|
||||
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
|
||||
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
|
||||
|
||||
-- name: InsertPlaylistItems :copyfrom
|
||||
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
|
||||
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
|
||||
|
||||
-- name: SetPlaylistSeq :exec
|
||||
UPDATE playlists SET item_seq = @item_seq, updated_at = now() WHERE id = @id;
|
||||
|
||||
|
||||
@@ -12,3 +12,9 @@ ON CONFLICT (source, track_id) DO UPDATE SET
|
||||
lyric = excluded.lyric, lyric_words = excluded.lyric_words, lyric_trans = excluded.lyric_trans,
|
||||
media_key = excluded.media_key, media_size = excluded.media_size,
|
||||
prepared_at = now();
|
||||
|
||||
-- name: TrackMetas :many
|
||||
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
|
||||
FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
|
||||
JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
|
||||
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id;
|
||||
@@ -9,6 +9,48 @@ import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// iteratorForInsertPlaylistItems implements pgx.CopyFromSource.
|
||||
type iteratorForInsertPlaylistItems struct {
|
||||
rows []InsertPlaylistItemsParams
|
||||
skippedFirstNextCall bool
|
||||
}
|
||||
|
||||
func (r *iteratorForInsertPlaylistItems) Next() bool {
|
||||
if len(r.rows) == 0 {
|
||||
return false
|
||||
}
|
||||
if !r.skippedFirstNextCall {
|
||||
r.skippedFirstNextCall = true
|
||||
return true
|
||||
}
|
||||
r.rows = r.rows[1:]
|
||||
return len(r.rows) > 0
|
||||
}
|
||||
|
||||
func (r iteratorForInsertPlaylistItems) Values() ([]interface{}, error) {
|
||||
return []interface{}{
|
||||
r.rows[0].PlaylistID,
|
||||
r.rows[0].Source,
|
||||
r.rows[0].TrackID,
|
||||
r.rows[0].Title,
|
||||
r.rows[0].Artist,
|
||||
r.rows[0].Album,
|
||||
r.rows[0].CoverURL,
|
||||
r.rows[0].CoverKey,
|
||||
r.rows[0].DurationMs,
|
||||
r.rows[0].PageURL,
|
||||
r.rows[0].Position,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r iteratorForInsertPlaylistItems) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Queries) InsertPlaylistItems(ctx context.Context, arg []InsertPlaylistItemsParams) (int64, error) {
|
||||
return q.db.CopyFrom(ctx, []string{"playlist_items"}, []string{"playlist_id", "source", "track_id", "title", "artist", "album", "cover_url", "cover_key", "duration_ms", "page_url", "position"}, &iteratorForInsertPlaylistItems{rows: arg})
|
||||
}
|
||||
|
||||
// iteratorForInsertPlays implements pgx.CopyFromSource.
|
||||
type iteratorForInsertPlays struct {
|
||||
rows []InsertPlaysParams
|
||||
|
||||
@@ -182,6 +182,20 @@ func (q *Queries) InsertPlaylistItem(ctx context.Context, arg InsertPlaylistItem
|
||||
return err
|
||||
}
|
||||
|
||||
type InsertPlaylistItemsParams struct {
|
||||
PlaylistID string
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
Album *string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
DurationMs int64
|
||||
PageURL string
|
||||
Position int64
|
||||
}
|
||||
|
||||
const lockFavorites = `-- name: LockFavorites :exec
|
||||
SELECT id FROM playlists WHERE owner_id = $1 AND kind = 'favorites' FOR UPDATE
|
||||
`
|
||||
|
||||
@@ -106,3 +106,57 @@ func (q *Queries) SavePreparedTrack(ctx context.Context, arg SavePreparedTrackPa
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const trackMetas = `-- name: TrackMetas :many
|
||||
SELECT t.source, t.track_id, t.title, t.artist, t.album, t.cover_url, t.cover_key, t.page_url, t.duration_ms
|
||||
FROM unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
|
||||
JOIN unnest($2::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
|
||||
JOIN tracks t ON t.source = s.source AND t.track_id = k.track_id
|
||||
`
|
||||
|
||||
type TrackMetasParams struct {
|
||||
Sources []string
|
||||
TrackIds []string
|
||||
}
|
||||
|
||||
type TrackMetasRow struct {
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
Album *string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
PageURL string
|
||||
DurationMs int64
|
||||
}
|
||||
|
||||
func (q *Queries) TrackMetas(ctx context.Context, arg TrackMetasParams) ([]TrackMetasRow, error) {
|
||||
rows, err := q.db.Query(ctx, trackMetas, arg.Sources, arg.TrackIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []TrackMetasRow
|
||||
for rows.Next() {
|
||||
var i TrackMetasRow
|
||||
if err := rows.Scan(
|
||||
&i.Source,
|
||||
&i.TrackID,
|
||||
&i.Title,
|
||||
&i.Artist,
|
||||
&i.Album,
|
||||
&i.CoverURL,
|
||||
&i.CoverKey,
|
||||
&i.PageURL,
|
||||
&i.DurationMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -49,19 +49,8 @@ func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.Pla
|
||||
|
||||
func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
|
||||
var created query.CreatePlaylistRow
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
if err := q.LockFavorites(ctx, ownerID); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := q.CountPlaylists(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n >= int64(limit) {
|
||||
return port.ErrPlaylistLimit
|
||||
}
|
||||
created, err = q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
|
||||
created, err = createPlaylist(ctx, s.q.WithTx(tx), ownerID, name, limit)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -70,6 +59,66 @@ func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit
|
||||
return port.PlaylistRecord{ID: created.ID, Name: name, CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ImportPlaylist(ctx context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
|
||||
var created query.CreatePlaylistRow
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) (err error) {
|
||||
q := s.q.WithTx(tx)
|
||||
if created, err = createPlaylist(ctx, q, ownerID, name, limit); err != nil {
|
||||
return err
|
||||
}
|
||||
seq := int64(len(items))
|
||||
rows := make([]query.InsertPlaylistItemsParams, len(items))
|
||||
for i, item := range items {
|
||||
t, m := item.Track, item.Meta
|
||||
rows[i] = query.InsertPlaylistItemsParams{
|
||||
PlaylistID: created.ID,
|
||||
Source: string(t.Source),
|
||||
TrackID: t.ID,
|
||||
Title: m.Title,
|
||||
Artist: nullable(m.Artist),
|
||||
Album: nullable(m.Album),
|
||||
CoverURL: nullable(m.CoverURL),
|
||||
CoverKey: nullable(string(m.CoverKey)),
|
||||
DurationMs: m.Duration.Milliseconds(),
|
||||
PageURL: m.PageURL,
|
||||
Position: seq - int64(i),
|
||||
}
|
||||
}
|
||||
if _, err = q.InsertPlaylistItems(ctx, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: created.ID, ItemSeq: seq})
|
||||
})
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 导入 %d 首歌到新歌单:%w", ownerID, len(items), err)
|
||||
}
|
||||
return port.PlaylistRecord{ID: created.ID, Name: name, TrackCount: len(items), CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt, Cover: firstCover(items)}, nil
|
||||
}
|
||||
|
||||
// 要在事务中调用:“我喜欢”的行锁持续到新建之后,同一个账号并发新建时才会依次计数。
|
||||
func createPlaylist(ctx context.Context, q *query.Queries, ownerID, name string, limit int) (query.CreatePlaylistRow, error) {
|
||||
if err := q.LockFavorites(ctx, ownerID); err != nil {
|
||||
return query.CreatePlaylistRow{}, err
|
||||
}
|
||||
n, err := q.CountPlaylists(ctx, ownerID)
|
||||
if err != nil {
|
||||
return query.CreatePlaylistRow{}, err
|
||||
}
|
||||
if n >= int64(limit) {
|
||||
return query.CreatePlaylistRow{}, port.ErrPlaylistLimit
|
||||
}
|
||||
return q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
|
||||
}
|
||||
|
||||
func firstCover(items []port.PlaylistItem) *port.Cover {
|
||||
for _, item := range items {
|
||||
if m := item.Meta; m.CoverURL != "" || m.CoverKey != "" {
|
||||
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RenamePlaylist(ctx context.Context, ownerID, id, name string) error {
|
||||
if !isUUID(id) {
|
||||
return port.ErrPlaylistNotFound
|
||||
|
||||
@@ -610,3 +610,82 @@ func TestMovePlaylistItemsAdvancesUpdatedAt(t *testing.T) {
|
||||
t.Error("移动之后更新时间没有推进")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPlaylist(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
items := []port.PlaylistItem{songItem("3"), clipItem, songItem("1")}
|
||||
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "导入的歌单", items, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || !slices.Equal(got, items) {
|
||||
t.Errorf("导入之后的曲目 = %#v, %v,期望与来源同序 %#v", got, err, items)
|
||||
}
|
||||
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
|
||||
if err != nil || len(lists) != 2 {
|
||||
t.Fatalf("歌单 = %#v, %v,期望“我喜欢”和导入的歌单", lists, err)
|
||||
}
|
||||
read := lists[1]
|
||||
if imported.ID != read.ID || imported.Name != "导入的歌单" || imported.Favorites || imported.TrackCount != 3 ||
|
||||
!imported.CreatedAt.Equal(read.CreatedAt) || !imported.UpdatedAt.Equal(read.UpdatedAt) {
|
||||
t.Errorf("导入返回 %#v,期望与读出的 %#v 一致、共 3 首", imported, read)
|
||||
}
|
||||
if want := (port.Cover{URL: songItem("3").Meta.CoverURL}); imported.Cover == nil || *imported.Cover != want || read.Cover == nil || *read.Cover != want {
|
||||
t.Errorf("封面 = %+v / %+v,期望排在最前的 %+v", imported.Cover, read.Cover, want)
|
||||
}
|
||||
if err := store.AddPlaylistItem(t.Context(), alice.ID, imported.ID, songItem("9"), 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := displayIDs(t, store, alice.ID, imported.ID); !slices.Equal(got, []string{"9", "3", clipItem.Track.ID, "1"}) {
|
||||
t.Errorf("导入之后再加入 = %v,期望新加入的排在最前", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportEmptyPlaylist(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
imported, err := store.ImportPlaylist(t.Context(), alice.ID, "空的", nil, 50)
|
||||
if err != nil || imported.Name != "空的" || imported.TrackCount != 0 || imported.Cover != nil {
|
||||
t.Fatalf("导入空列表 = %#v, %v,期望建出一个没有曲目的歌单", imported, err)
|
||||
}
|
||||
if got, err := store.PlaylistItems(t.Context(), alice.ID, imported.ID); err != nil || len(got) != 0 {
|
||||
t.Errorf("曲目 = %#v, %v,期望为空", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPlaylistRollsBackAsAWhole(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
items := []port.PlaylistItem{songItem("1"), songItem("2")}
|
||||
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "超限", items, 1); !errors.Is(err, port.ErrPlaylistLimit) {
|
||||
t.Errorf("歌单数到了上限 = %v,期望 ErrPlaylistLimit", err)
|
||||
}
|
||||
broken := songItem("3")
|
||||
broken.Meta.Title = "坏\x00标题"
|
||||
if _, err := store.ImportPlaylist(t.Context(), alice.ID, "写入失败", append(items, broken), 50); err == nil {
|
||||
t.Error("有一首写不进去时应报错")
|
||||
}
|
||||
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
|
||||
t.Errorf("失败之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items`).Scan(&count); err != nil || count != 0 {
|
||||
t.Errorf("失败之后的曲目数 = %d, %v,期望 0", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentImportsRespectLimit(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
var wg sync.WaitGroup
|
||||
for i := range 10 {
|
||||
wg.Go(func() {
|
||||
_, _ = store.ImportPlaylist(t.Context(), alice.ID, "导入"+strconv.Itoa(i), []port.PlaylistItem{songItem(strconv.Itoa(i))}, 5)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
|
||||
t.Errorf("并发导入之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var _ port.Tracks = (*Store)(nil)
|
||||
var (
|
||||
_ port.Tracks = (*Store)(nil)
|
||||
_ port.TrackLookup = (*Store)(nil)
|
||||
)
|
||||
|
||||
func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.PreparedTrack, bool, error) {
|
||||
row, err := s.q.PreparedTrack(ctx, query.PreparedTrackParams{Source: string(t.Source), TrackID: t.ID})
|
||||
@@ -27,6 +30,19 @@ func (s *Store) PreparedTrack(ctx context.Context, t track.Track) (port.Prepared
|
||||
return port.PreparedTrack{Meta: meta, Result: result, MediaKey: orEmpty(row.MediaKey), MediaSize: orZero(row.MediaSize)}, true, nil
|
||||
}
|
||||
|
||||
func (s *Store) TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error) {
|
||||
sources, ids := splitTracks(ts)
|
||||
rows, err := s.q.TrackMetas(ctx, query.TrackMetasParams{Sources: sources, TrackIds: ids})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 %d 首歌的元数据:%w", len(ts), err)
|
||||
}
|
||||
found := make(map[track.Track]track.Meta, len(rows))
|
||||
for _, r := range rows {
|
||||
found[track.Track{Source: track.Source(r.Source), ID: r.TrackID}] = storedMeta(r.Title, orEmpty(r.Artist), orEmpty(r.Album), orEmpty(r.CoverURL), orEmpty(r.CoverKey), r.PageURL, r.DurationMs)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.PreparedTrack) error {
|
||||
params := query.SavePreparedTrackParams{
|
||||
Source: string(t.Source),
|
||||
@@ -53,9 +69,12 @@ func (s *Store) SavePreparedTrack(ctx context.Context, t track.Track, p port.Pre
|
||||
}
|
||||
|
||||
func prepared(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64, gain float64, lyric, lyricWords, lyricTrans string) (track.Meta, track.Prepared) {
|
||||
d := time.Duration(durationMs) * time.Millisecond
|
||||
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: d},
|
||||
track.Prepared{Duration: d, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
|
||||
meta := storedMeta(title, artist, album, coverURL, coverKey, pageURL, durationMs)
|
||||
return meta, track.Prepared{Duration: meta.Duration, Gain: gain, Lyric: lyric, LyricWords: lyricWords, LyricTrans: lyricTrans}
|
||||
}
|
||||
|
||||
func storedMeta(title, artist, album, coverURL, coverKey, pageURL string, durationMs int64) track.Meta {
|
||||
return track.Meta{Title: title, Artist: artist, Album: album, CoverURL: coverURL, CoverKey: track.ObjectKey(coverKey), PageURL: pageURL, Duration: time.Duration(durationMs) * time.Millisecond}
|
||||
}
|
||||
|
||||
func orZero(p *int64) int64 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -124,3 +125,42 @@ func TestLinkTrackRoundTrip(t *testing.T) {
|
||||
t.Errorf("恢复出的条目 = %+v,期望已就绪,元数据取自曲目表", snap.Waiting[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackMetas(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
link := track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
|
||||
saved := map[track.Track]port.PreparedTrack{
|
||||
netease("1"): {
|
||||
Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=1", Duration: time.Minute},
|
||||
Result: track.Prepared{Duration: 61500 * time.Millisecond, Gain: 0.5, Lyric: "词", LyricWords: "逐字", LyricTrans: "翻译"},
|
||||
},
|
||||
link: {
|
||||
Meta: track.Meta{Title: "a.mp3", CoverKey: "link/abc/cover.webp", PageURL: link.ID, Duration: 3 * time.Second},
|
||||
Result: track.Prepared{Duration: 3 * time.Second, Gain: 1},
|
||||
MediaKey: "abc",
|
||||
MediaSize: 12290,
|
||||
},
|
||||
}
|
||||
want := map[track.Track]track.Meta{}
|
||||
for tr, p := range saved {
|
||||
if err := store.SavePreparedTrack(t.Context(), tr, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
read, _, err := store.PreparedTrack(t.Context(), tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want[tr] = read.Meta
|
||||
}
|
||||
if want[netease("1")].Duration != 61500*time.Millisecond {
|
||||
t.Fatalf("PreparedTrack 读出的时长 = %v,期望准备得到的时长", want[netease("1")].Duration)
|
||||
}
|
||||
wanted := []track.Track{netease("2"), netease("1"), {Source: track.QQ, ID: "1"}, link, netease("1")}
|
||||
got, err := store.TrackMetas(t.Context(), wanted)
|
||||
if err != nil || !maps.Equal(got, want) {
|
||||
t.Errorf("批量读取 = %#v, %v\n期望只有命中的、与 PreparedTrack 读出的元数据一致 %#v:音源和 ID 都要对上,重复的只算一次", got, err, want)
|
||||
}
|
||||
if got, err := store.TrackMetas(t.Context(), nil); err != nil || len(got) != 0 {
|
||||
t.Errorf("什么也不查 = %#v, %v,期望空的结果", got, err)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ type Playlists interface {
|
||||
MemberPlaylists(ctx context.Context, ownerID string) ([]PlaylistRecord, error)
|
||||
// 已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
|
||||
CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (PlaylistRecord, error)
|
||||
// 新建歌单并写入 items,items[0] 排在最前;返回的记录带曲目数和封面。已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
|
||||
// 调用方保证 items 中没有重复的歌、不超过歌单的曲目上限。
|
||||
ImportPlaylist(ctx context.Context, ownerID, name string, items []PlaylistItem, limit int) (PlaylistRecord, error)
|
||||
// “我喜欢”返回 ErrFavoritesFixed。
|
||||
RenamePlaylist(ctx context.Context, ownerID, id, name string) error
|
||||
// “我喜欢”返回 ErrFavoritesFixed。
|
||||
|
||||
@@ -591,6 +591,44 @@ func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit in
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, err := s.newPlaylist(ownerID, name, limit)
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: p.created, UpdatedAt: p.updated}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ImportPlaylist(_ context.Context, ownerID, name string, items []port.PlaylistItem, limit int) (port.PlaylistRecord, error) {
|
||||
if err := checkText(name); err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
seen := make(map[track.Track]bool, len(items))
|
||||
for _, item := range items {
|
||||
m := item.Meta
|
||||
if err := checkText(item.Track.ID, m.Title, m.Artist, m.Album, m.CoverURL, string(m.CoverKey), m.PageURL); err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
if seen[item.Track] {
|
||||
return port.PlaylistRecord{}, fmt.Errorf("歌单里已经有 %s/%s:违反主键约束", item.Track.Source, item.Track.ID)
|
||||
}
|
||||
seen[item.Track] = true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, err := s.newPlaylist(ownerID, name, limit)
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
for i := len(items) - 1; i >= 0; i-- {
|
||||
item := items[i]
|
||||
item.Meta.Duration = item.Meta.Duration.Truncate(time.Millisecond)
|
||||
p.items = append(p.items, item)
|
||||
}
|
||||
return port.PlaylistRecord{ID: p.id, Name: name, TrackCount: len(p.items), CreatedAt: p.created, UpdatedAt: p.updated, Cover: p.cover()}, nil
|
||||
}
|
||||
|
||||
// 调用方持有 s.mu。
|
||||
func (s *Store) newPlaylist(ownerID, name string, limit int) (*storedPlaylist, error) {
|
||||
n := 0
|
||||
for _, p := range s.playlists {
|
||||
if p.owner == ownerID {
|
||||
@@ -598,12 +636,12 @@ func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit in
|
||||
}
|
||||
}
|
||||
if n >= limit {
|
||||
return port.PlaylistRecord{}, port.ErrPlaylistLimit
|
||||
return nil, port.ErrPlaylistLimit
|
||||
}
|
||||
now := time.Now()
|
||||
p := &storedPlaylist{id: s.newID(), owner: ownerID, name: name, created: now, updated: now}
|
||||
s.playlists = append(s.playlists, p)
|
||||
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: now, UpdatedAt: now}, nil
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenamePlaylist(_ context.Context, ownerID, id, name string) error {
|
||||
|
||||
@@ -45,3 +45,21 @@ func TestAddPlaylistItemKeepsMilliseconds(t *testing.T) {
|
||||
t.Errorf("Duration = %v,期望 %v", items[0].Meta.Duration, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportPlaylistRejectsWhatPostgreSQLRejects(t *testing.T) {
|
||||
store := New()
|
||||
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
song := port.PlaylistItem{Track: track.Track{Source: "netease", ID: "1"}, Meta: track.Meta{Title: "歌"}}
|
||||
broken := port.PlaylistItem{Track: track.Track{Source: "netease", ID: "2"}, Meta: track.Meta{Title: "坏\x00标题"}}
|
||||
for name, items := range map[string][]port.PlaylistItem{"重复的歌": {song, song}, "含 NUL 的标题": {song, broken}} {
|
||||
if _, err := store.ImportPlaylist(t.Context(), m.ID, "导入", items, 10); err == nil {
|
||||
t.Errorf("%s应报错", name)
|
||||
}
|
||||
}
|
||||
if lists, _ := store.MemberPlaylists(t.Context(), m.ID); len(lists) != 0 {
|
||||
t.Errorf("失败之后的歌单 = %#v,期望一个也没有建", lists)
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,9 @@ type Tracks interface {
|
||||
PreparedTrack(ctx context.Context, t track.Track) (p PreparedTrack, ok bool, err error)
|
||||
SavePreparedTrack(ctx context.Context, t track.Track, p PreparedTrack) error
|
||||
}
|
||||
|
||||
type TrackLookup interface {
|
||||
PreparedTrack(ctx context.Context, t track.Track) (p PreparedTrack, ok bool, err error)
|
||||
// 与 PreparedTrack 读出的 Meta 相同,时长是准备得到的时长。
|
||||
TrackMetas(ctx context.Context, ts []track.Track) (map[track.Track]track.Meta, error)
|
||||
}
|
||||
Reference in new issue
Block a user