From ed49e3520f1b8deecc36734501def1f1cf6a1549 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Tue, 10 Nov 2026 09:50:31 +0800 Subject: [PATCH] feat: look up prepared tracks in bulk and create a playlist with its songs at once --- db/queries/playlists.sql | 4 + db/queries/tracks.sql | 6 ++ .../out/postgres/internal/query/copyfrom.go | 42 ++++++++++ .../postgres/internal/query/playlists.sql.go | 14 ++++ .../out/postgres/internal/query/tracks.sql.go | 54 +++++++++++++ internal/adapter/out/postgres/playlists.go | 75 +++++++++++++++--- .../adapter/out/postgres/playlists_test.go | 79 +++++++++++++++++++ internal/adapter/out/postgres/tracks.go | 27 ++++++- internal/adapter/out/postgres/tracks_test.go | 40 ++++++++++ internal/app/port/playlist.go | 3 + internal/app/port/porttest/store.go | 42 +++++++++- internal/app/port/porttest/store_test.go | 18 +++++ internal/app/port/tracks.go | 6 ++ 13 files changed, 391 insertions(+), 19 deletions(-) diff --git a/db/queries/playlists.sql b/db/queries/playlists.sql index bbd88bb..bf7ac8d 100644 --- a/db/queries/playlists.sql +++ b/db/queries/playlists.sql @@ -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; diff --git a/db/queries/tracks.sql b/db/queries/tracks.sql index d9a571c..0655c68 100644 --- a/db/queries/tracks.sql +++ b/db/queries/tracks.sql @@ -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; diff --git a/internal/adapter/out/postgres/internal/query/copyfrom.go b/internal/adapter/out/postgres/internal/query/copyfrom.go index 0b95983..27b6272 100644 --- a/internal/adapter/out/postgres/internal/query/copyfrom.go +++ b/internal/adapter/out/postgres/internal/query/copyfrom.go @@ -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 diff --git a/internal/adapter/out/postgres/internal/query/playlists.sql.go b/internal/adapter/out/postgres/internal/query/playlists.sql.go index 9be6d29..8820360 100644 --- a/internal/adapter/out/postgres/internal/query/playlists.sql.go +++ b/internal/adapter/out/postgres/internal/query/playlists.sql.go @@ -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 ` diff --git a/internal/adapter/out/postgres/internal/query/tracks.sql.go b/internal/adapter/out/postgres/internal/query/tracks.sql.go index 007b993..bd419c9 100644 --- a/internal/adapter/out/postgres/internal/query/tracks.sql.go +++ b/internal/adapter/out/postgres/internal/query/tracks.sql.go @@ -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 +} diff --git a/internal/adapter/out/postgres/playlists.go b/internal/adapter/out/postgres/playlists.go index 6ae6f05..3afdc0d 100644 --- a/internal/adapter/out/postgres/playlists.go +++ b/internal/adapter/out/postgres/playlists.go @@ -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 diff --git a/internal/adapter/out/postgres/playlists_test.go b/internal/adapter/out/postgres/playlists_test.go index b389559..9e67fae 100644 --- a/internal/adapter/out/postgres/playlists_test.go +++ b/internal/adapter/out/postgres/playlists_test.go @@ -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) + } +} diff --git a/internal/adapter/out/postgres/tracks.go b/internal/adapter/out/postgres/tracks.go index 730d5cb..92bbd5b 100644 --- a/internal/adapter/out/postgres/tracks.go +++ b/internal/adapter/out/postgres/tracks.go @@ -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 { diff --git a/internal/adapter/out/postgres/tracks_test.go b/internal/adapter/out/postgres/tracks_test.go index 9b65fbc..c26f5fb 100644 --- a/internal/adapter/out/postgres/tracks_test.go +++ b/internal/adapter/out/postgres/tracks_test.go @@ -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) + } +} diff --git a/internal/app/port/playlist.go b/internal/app/port/playlist.go index b1f0a80..2fa9924 100644 --- a/internal/app/port/playlist.go +++ b/internal/app/port/playlist.go @@ -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。 diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go index 597f144..2a84d5d 100644 --- a/internal/app/port/porttest/store.go +++ b/internal/app/port/porttest/store.go @@ -578,6 +578,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 { @@ -585,12 +623,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 { diff --git a/internal/app/port/porttest/store_test.go b/internal/app/port/porttest/store_test.go index 2da1543..6d6ae6d 100644 --- a/internal/app/port/porttest/store_test.go +++ b/internal/app/port/porttest/store_test.go @@ -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) + } +} diff --git a/internal/app/port/tracks.go b/internal/app/port/tracks.go index 530d9f8..5f0832c 100644 --- a/internal/app/port/tracks.go +++ b/internal/app/port/tracks.go @@ -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) +}