feat: store playlists with a fixed favorites list for every account

This commit is contained in:
DiheChen committed 2026-11-09 21:25:18 +08:00
1 parent f0f38ff511
commit ffbf28c165
11 files changed
+1263 -7

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
-- +goose Up
CREATE TABLE playlists (
id uuid PRIMARY KEY DEFAULT uuidv7(),
owner_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
-- 每个账号恰好一个“我喜欢”:注册时创建,不能改名或删除。
kind text NOT NULL CHECK (kind IN ('favorites', 'custom')),
name text NOT NULL,
-- 新加入的曲目取 item_seq + 1 作为排序值,排在最前。
item_seq bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
-- 歌单改名或曲目增减的时间。
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON playlists (owner_id);
CREATE UNIQUE INDEX ON playlists (owner_id) WHERE kind = 'favorites';
-- 展示用的元数据存一份快照:歌单里的歌不一定准备过,tracks 表里可能没有。
CREATE TABLE playlist_items (
playlist_id uuid NOT NULL REFERENCES playlists ON DELETE CASCADE,
source text NOT NULL,
track_id text NOT NULL,
title text NOT NULL,
artist text,
album text,
cover_url text,
cover_key text,
duration_ms bigint NOT NULL,
page_url text NOT NULL,
position bigint NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (playlist_id, source, track_id)
);
CREATE INDEX ON playlist_items (playlist_id, position DESC);
+64
View File
@@ -0,0 +1,64 @@
-- name: CreateFavorites :exec
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
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = @owner_id
GROUP BY p.id
ORDER BY p.kind = 'favorites' DESC, p.created_at, p.id;
-- name: MemberPlaylistCovers :many
SELECT DISTINCT ON (i.playlist_id) i.playlist_id, i.cover_url, i.cover_key
FROM playlist_items i
JOIN playlists p ON p.id = i.playlist_id
WHERE p.owner_id = @owner_id AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT NULL)
ORDER BY i.playlist_id, i.position DESC;
-- name: LockFavorites :exec
-- 新建歌单前锁住这个账号的“我喜欢”:同一个账号并发新建时依次计数,不会越过上限。
SELECT id FROM playlists WHERE owner_id = @owner_id AND kind = 'favorites' FOR UPDATE;
-- name: CountPlaylists :one
SELECT count(*) FROM playlists WHERE owner_id = @owner_id;
-- name: CreatePlaylist :one
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'custom', @name)
RETURNING id, created_at, updated_at;
-- name: PlaylistKind :one
SELECT kind FROM playlists WHERE id = @id AND owner_id = @owner_id;
-- name: RenamePlaylist :execrows
UPDATE playlists SET name = @name, updated_at = now() WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
-- name: DeletePlaylist :execrows
DELETE FROM playlists WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
-- name: LockPlaylist :one
-- 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
SELECT item_seq FROM playlists WHERE id = @id AND owner_id = @owner_id FOR UPDATE;
-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = @playlist_id AND source = @source AND track_id = @track_id);
-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = @playlist_id;
-- name: InsertPlaylistItem :exec
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;
-- name: PlaylistItems :many
SELECT * FROM playlist_items WHERE playlist_id = @playlist_id ORDER BY position DESC;
-- name: DeletePlaylistItem :exec
WITH deleted AS (
DELETE FROM playlist_items WHERE playlist_id = @playlist_id AND source = @source AND track_id = @track_id
RETURNING playlist_id
)
UPDATE playlists SET updated_at = now() WHERE id IN (SELECT playlist_id FROM deleted);
@@ -18,6 +18,21 @@ type Member struct {
Email *string
}
type PlaylistItem struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
AddedAt time.Time
}
type Room struct {
ID string
Kind string
@@ -0,0 +1,345 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: playlists.sql
package query
import (
"context"
"time"
)
const countPlaylistItems = `-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = $1
`
func (q *Queries) CountPlaylistItems(ctx context.Context, playlistID string) (int64, error) {
row := q.db.QueryRow(ctx, countPlaylistItems, playlistID)
var count int64
err := row.Scan(&count)
return count, err
}
const countPlaylists = `-- name: CountPlaylists :one
SELECT count(*) FROM playlists WHERE owner_id = $1
`
func (q *Queries) CountPlaylists(ctx context.Context, ownerID string) (int64, error) {
row := q.db.QueryRow(ctx, countPlaylists, ownerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createFavorites = `-- name: CreateFavorites :exec
INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'favorites', '我喜欢')
`
func (q *Queries) CreateFavorites(ctx context.Context, ownerID string) error {
_, err := q.db.Exec(ctx, createFavorites, ownerID)
return err
}
const createPlaylist = `-- name: CreatePlaylist :one
INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'custom', $2)
RETURNING id, created_at, updated_at
`
type CreatePlaylistParams struct {
OwnerID string
Name string
}
type CreatePlaylistRow struct {
ID string
CreatedAt time.Time
UpdatedAt time.Time
}
func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) (CreatePlaylistRow, error) {
row := q.db.QueryRow(ctx, createPlaylist, arg.OwnerID, arg.Name)
var i CreatePlaylistRow
err := row.Scan(&i.ID, &i.CreatedAt, &i.UpdatedAt)
return i, err
}
const deletePlaylist = `-- name: DeletePlaylist :execrows
DELETE FROM playlists WHERE id = $1 AND owner_id = $2 AND kind = 'custom'
`
type DeletePlaylistParams struct {
ID string
OwnerID string
}
func (q *Queries) DeletePlaylist(ctx context.Context, arg DeletePlaylistParams) (int64, error) {
result, err := q.db.Exec(ctx, deletePlaylist, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const deletePlaylistItem = `-- name: DeletePlaylistItem :exec
WITH deleted AS (
DELETE FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3
RETURNING playlist_id
)
UPDATE playlists SET updated_at = now() WHERE id IN (SELECT playlist_id FROM deleted)
`
type DeletePlaylistItemParams struct {
PlaylistID string
Source string
TrackID string
}
func (q *Queries) DeletePlaylistItem(ctx context.Context, arg DeletePlaylistItemParams) error {
_, err := q.db.Exec(ctx, deletePlaylistItem, arg.PlaylistID, arg.Source, arg.TrackID)
return err
}
const insertPlaylistItem = `-- name: InsertPlaylistItem :exec
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
type InsertPlaylistItemParams struct {
PlaylistID string
Source string
TrackID string
Title string
Artist *string
Album *string
CoverURL *string
CoverKey *string
DurationMs int64
PageURL string
Position int64
}
func (q *Queries) InsertPlaylistItem(ctx context.Context, arg InsertPlaylistItemParams) error {
_, err := q.db.Exec(ctx, insertPlaylistItem,
arg.PlaylistID,
arg.Source,
arg.TrackID,
arg.Title,
arg.Artist,
arg.Album,
arg.CoverURL,
arg.CoverKey,
arg.DurationMs,
arg.PageURL,
arg.Position,
)
return err
}
const lockFavorites = `-- name: LockFavorites :exec
SELECT id FROM playlists WHERE owner_id = $1 AND kind = 'favorites' FOR UPDATE
`
// 新建歌单前锁住这个账号的“我喜欢”:同一个账号并发新建时依次计数,不会越过上限。
func (q *Queries) LockFavorites(ctx context.Context, ownerID string) error {
_, err := q.db.Exec(ctx, lockFavorites, ownerID)
return err
}
const lockPlaylist = `-- name: LockPlaylist :one
SELECT item_seq FROM playlists WHERE id = $1 AND owner_id = $2 FOR UPDATE
`
type LockPlaylistParams struct {
ID string
OwnerID string
}
// 改动曲目前锁住歌单:并发加入时依次检查上限、分配排序值。删除歌单先锁歌单行再级联删除曲目,其他操作也要先锁歌单再动曲目,否则会互相死锁。
func (q *Queries) LockPlaylist(ctx context.Context, arg LockPlaylistParams) (int64, error) {
row := q.db.QueryRow(ctx, lockPlaylist, arg.ID, arg.OwnerID)
var item_seq int64
err := row.Scan(&item_seq)
return item_seq, err
}
const memberPlaylistCovers = `-- name: MemberPlaylistCovers :many
SELECT DISTINCT ON (i.playlist_id) i.playlist_id, i.cover_url, i.cover_key
FROM playlist_items i
JOIN playlists p ON p.id = i.playlist_id
WHERE p.owner_id = $1 AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT NULL)
ORDER BY i.playlist_id, i.position DESC
`
type MemberPlaylistCoversRow struct {
PlaylistID string
CoverURL *string
CoverKey *string
}
func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]MemberPlaylistCoversRow, error) {
rows, err := q.db.Query(ctx, memberPlaylistCovers, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberPlaylistCoversRow
for rows.Next() {
var i MemberPlaylistCoversRow
if err := rows.Scan(&i.PlaylistID, &i.CoverURL, &i.CoverKey); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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
FROM playlists p
LEFT JOIN playlist_items i ON i.playlist_id = p.id
WHERE p.owner_id = $1
GROUP BY p.id
ORDER BY p.kind = 'favorites' DESC, p.created_at, p.id
`
type MemberPlaylistsRow struct {
ID string
Kind string
Name string
CreatedAt time.Time
UpdatedAt time.Time
TrackCount int64
}
func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]MemberPlaylistsRow, error) {
rows, err := q.db.Query(ctx, memberPlaylists, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MemberPlaylistsRow
for rows.Next() {
var i MemberPlaylistsRow
if err := rows.Scan(
&i.ID,
&i.Kind,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
&i.TrackCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const playlistHasItem = `-- name: PlaylistHasItem :one
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3)
`
type PlaylistHasItemParams struct {
PlaylistID string
Source string
TrackID string
}
func (q *Queries) PlaylistHasItem(ctx context.Context, arg PlaylistHasItemParams) (bool, error) {
row := q.db.QueryRow(ctx, playlistHasItem, arg.PlaylistID, arg.Source, arg.TrackID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const playlistItems = `-- name: PlaylistItems :many
SELECT playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position, added_at FROM playlist_items WHERE playlist_id = $1 ORDER BY position DESC
`
func (q *Queries) PlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error) {
rows, err := q.db.Query(ctx, playlistItems, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PlaylistItem
for rows.Next() {
var i PlaylistItem
if err := rows.Scan(
&i.PlaylistID,
&i.Source,
&i.TrackID,
&i.Title,
&i.Artist,
&i.Album,
&i.CoverURL,
&i.CoverKey,
&i.DurationMs,
&i.PageURL,
&i.Position,
&i.AddedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const playlistKind = `-- name: PlaylistKind :one
SELECT kind FROM playlists WHERE id = $1 AND owner_id = $2
`
type PlaylistKindParams struct {
ID string
OwnerID string
}
func (q *Queries) PlaylistKind(ctx context.Context, arg PlaylistKindParams) (string, error) {
row := q.db.QueryRow(ctx, playlistKind, arg.ID, arg.OwnerID)
var kind string
err := row.Scan(&kind)
return kind, err
}
const renamePlaylist = `-- name: RenamePlaylist :execrows
UPDATE playlists SET name = $1, updated_at = now() WHERE id = $2 AND owner_id = $3 AND kind = 'custom'
`
type RenamePlaylistParams struct {
Name string
ID string
OwnerID string
}
func (q *Queries) RenamePlaylist(ctx context.Context, arg RenamePlaylistParams) (int64, error) {
result, err := q.db.Exec(ctx, renamePlaylist, arg.Name, arg.ID, arg.OwnerID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const setPlaylistSeq = `-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = $1, updated_at = now() WHERE id = $2
`
type SetPlaylistSeqParams struct {
ItemSeq int64
ID string
}
func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams) error {
_, err := q.db.Exec(ctx, setPlaylistSeq, arg.ItemSeq, arg.ID)
return err
}
+12 -5
View File
@@ -70,12 +70,19 @@ func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.Accou
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
n, err := s.q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
n, err := q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
if err != nil {
return usernameError(err)
}
if n == 0 {
return port.ErrAlreadyRegistered
}
return q.CreateFavorites(ctx, memberID)
})
if err != nil {
return fmt.Errorf("注册成员 %s:%w", memberID, usernameError(err))
}
if n == 0 {
return fmt.Errorf("注册成员 %s:%w", memberID, port.ErrAlreadyRegistered)
return fmt.Errorf("注册成员 %s:%w", memberID, err)
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
var _ port.Playlists = (*Store)(nil)
const favoritesKind = "favorites"
func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.PlaylistRecord, error) {
rows, err := s.q.MemberPlaylists(ctx, ownerID)
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的歌单:%w", ownerID, err)
}
coverRows, err := s.q.MemberPlaylistCovers(ctx, ownerID)
if err != nil {
return nil, fmt.Errorf("读取成员 %s 的歌单封面:%w", ownerID, err)
}
covers := make(map[string]*port.Cover, len(coverRows))
for _, c := range coverRows {
covers[c.PlaylistID] = &port.Cover{URL: orEmpty(c.CoverURL), Key: track.ObjectKey(orEmpty(c.CoverKey))}
}
lists := make([]port.PlaylistRecord, len(rows))
for i, r := range rows {
lists[i] = port.PlaylistRecord{
ID: r.ID,
Name: r.Name,
Favorites: r.Kind == favoritesKind,
TrackCount: int(r.TrackCount),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
Cover: covers[r.ID],
}
}
return lists, nil
}
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})
return err
})
if err != nil {
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 新建歌单:%w", ownerID, err)
}
return port.PlaylistRecord{ID: created.ID, Name: name, CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}, nil
}
func (s *Store) RenamePlaylist(ctx context.Context, ownerID, id, name string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.RenamePlaylist(ctx, query.RenamePlaylistParams{ID: id, OwnerID: ownerID, Name: name})
if err == nil && n == 0 {
err = s.unchangeable(ctx, ownerID, id)
}
if err != nil {
return fmt.Errorf("歌单 %s 改名:%w", id, err)
}
return nil
}
func (s *Store) DeletePlaylist(ctx context.Context, ownerID, id string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
n, err := s.q.DeletePlaylist(ctx, query.DeletePlaylistParams{ID: id, OwnerID: ownerID})
if err == nil && n == 0 {
err = s.unchangeable(ctx, ownerID, id)
}
if err != nil {
return fmt.Errorf("删除歌单 %s:%w", id, err)
}
return nil
}
// 改名或删除没有影响任何行:要么是“我喜欢”,要么歌单不属于这个成员。
func (s *Store) unchangeable(ctx context.Context, ownerID, id string) error {
kind, err := s.q.PlaylistKind(ctx, query.PlaylistKindParams{ID: id, OwnerID: ownerID})
switch {
case errors.Is(err, pgx.ErrNoRows):
return port.ErrPlaylistNotFound
case err != nil:
return err
case kind == favoritesKind:
return port.ErrFavoritesFixed
}
return port.ErrPlaylistNotFound
}
func (s *Store) owned(ctx context.Context, ownerID, id string) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
_, err := s.q.PlaylistKind(ctx, query.PlaylistKindParams{ID: id, OwnerID: ownerID})
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
}
return err
}
func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.PlaylistItem, error) {
if err := s.owned(ctx, ownerID, id); err != nil {
return nil, fmt.Errorf("读取歌单 %s:%w", id, err)
}
rows, err := s.q.PlaylistItems(ctx, id)
if err != nil {
return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err)
}
items := make([]port.PlaylistItem, len(rows))
for i, r := range rows {
items[i] = port.PlaylistItem{
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
Meta: track.Meta{
Title: r.Title,
Artist: orEmpty(r.Artist),
Album: orEmpty(r.Album),
CoverURL: orEmpty(r.CoverURL),
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
PageURL: r.PageURL,
Duration: time.Duration(r.DurationMs) * time.Millisecond,
},
}
}
return items, nil
}
func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
t, m := item.Track, item.Meta
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
seq, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID})
if errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
}
if err != nil {
return err
}
has, err := q.PlaylistHasItem(ctx, query.PlaylistHasItemParams{PlaylistID: id, Source: string(t.Source), TrackID: t.ID})
if err != nil || has {
return err
}
n, err := q.CountPlaylistItems(ctx, id)
if err != nil {
return err
}
if n >= int64(limit) {
return port.ErrPlaylistLimit
}
seq++
if err := q.InsertPlaylistItem(ctx, query.InsertPlaylistItemParams{
PlaylistID: 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,
}); err != nil {
return err
}
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: id, ItemSeq: seq})
})
if err != nil {
return fmt.Errorf("向歌单 %s 加入 %s/%s:%w", id, t.Source, t.ID, err)
}
return nil
}
func (s *Store) RemovePlaylistItem(ctx context.Context, ownerID, id string, t track.Track) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := s.q.WithTx(tx)
if _, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID}); errors.Is(err, pgx.ErrNoRows) {
return port.ErrPlaylistNotFound
} else if err != nil {
return err
}
return q.DeletePlaylistItem(ctx, query.DeletePlaylistItemParams{PlaylistID: id, Source: string(t.Source), TrackID: t.ID})
})
if err != nil {
return fmt.Errorf("从歌单 %s 移除 %s/%s:%w", id, t.Source, t.ID, err)
}
return nil
}
@@ -0,0 +1,326 @@
package postgres
import (
"errors"
"slices"
"strconv"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
"github.com/dihemoe/alisten/internal/domain/track"
)
func registered(t *testing.T, store *Store, username string) member.Member {
t.Helper()
m := createMember(t, store, username, "游客-"+username)
if err := store.Register(t.Context(), m.ID, username, "hash"); err != nil {
t.Fatal(err)
}
return m
}
func favoritesOf(t *testing.T, store *Store, ownerID string) string {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), ownerID)
if err != nil || len(lists) == 0 || !lists[0].Favorites {
t.Fatalf("歌单 = %#v, %v,期望第一个是“我喜欢”", lists, err)
}
return lists[0].ID
}
var (
sunnyItem = port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: 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}}
clipItem = port.PlaylistItem{Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}, Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute}}
)
func TestRegisterCreatesFavorites(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 1 || !lists[0].Favorites || lists[0].Name != "我喜欢" || lists[0].TrackCount != 0 {
t.Fatalf("注册之后的歌单 = %#v, %v,期望只有一个空的“我喜欢”", lists, err)
}
guest := createMember(t, store, "guest", "游客-1a2b")
if lists, err := store.MemberPlaylists(t.Context(), guest.ID); err != nil || len(lists) != 0 {
t.Errorf("游客的歌单 = %#v, %v,期望没有", lists, err)
}
}
func TestPlaylistLifecycle(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
favorites := favoritesOf(t, store, alice.ID)
commute, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
if err := store.RenamePlaylist(t.Context(), alice.ID, commute.ID, "上班路上"); err != nil {
t.Fatal(err)
}
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 || lists[1].ID != commute.ID || lists[1].Name != "上班路上" || lists[1].TrackCount != 0 || lists[1].Cover != nil {
t.Errorf("歌单 = %#v, %v,期望“我喜欢”之后是改名后的 %s", lists, err, commute.ID)
}
if err := store.RenamePlaylist(t.Context(), alice.ID, favorites, "别的名字"); !errors.Is(err, port.ErrFavoritesFixed) {
t.Errorf("“我喜欢”改名 = %v,期望 ErrFavoritesFixed", err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, favorites); !errors.Is(err, port.ErrFavoritesFixed) {
t.Errorf("删除“我喜欢” = %v,期望 ErrFavoritesFixed", err)
}
for _, id := range []string{commute.ID, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
if err := store.RenamePlaylist(t.Context(), bob.ID, id, "x"); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("别人或不存在的歌单 %s 改名 = %v,期望 ErrPlaylistNotFound", id, err)
}
if err := store.DeletePlaylist(t.Context(), bob.ID, id); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("删除别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
if _, err := store.PlaylistItems(t.Context(), bob.ID, id); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("读取别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
}
}
if err := store.DeletePlaylist(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
t.Errorf("删除之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
}
}
func TestDeletePlaylistRemovesItsItems(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
commute, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, commute.ID, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
if err := store.DeletePlaylist(t.Context(), alice.ID, commute.ID); err != nil {
t.Fatal(err)
}
var count int
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items WHERE playlist_id = $1`, commute.ID).Scan(&count); err != nil || count != 0 {
t.Errorf("删除歌单之后的曲目数 = %d, %v,期望 0", count, err)
}
}
func TestAccountHasOneFavorites(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
_, err := pool.Exec(t.Context(), `INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'favorites', '我喜欢')`, alice.ID)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != uniqueViolation {
t.Errorf("第二个“我喜欢” = %v,期望唯一约束错误", err)
}
}
func TestPlaylistLimitCountsFavorites(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
if _, err := store.CreatePlaylist(t.Context(), alice.ID, "一", 2); err != nil {
t.Fatal(err)
}
if _, err := store.CreatePlaylist(t.Context(), alice.ID, "二", 2); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("超过上限时 = %v,期望 ErrPlaylistLimit", err)
}
}
func TestPlaylistItems(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
bob := registered(t, store, "bob")
favorites := favoritesOf(t, store, alice.ID)
for _, item := range []port.PlaylistItem{sunnyItem, clipItem, sunnyItem} {
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 2); err != nil {
t.Fatal(err)
}
}
items, err := store.PlaylistItems(t.Context(), alice.ID, favorites)
if want := []port.PlaylistItem{clipItem, sunnyItem}; err != nil || !slices.Equal(items, want) {
t.Errorf("曲目 = %#v, %v,期望新加入的在前、重复加入不变 %#v", items, err, want)
}
if lists, _ := store.MemberPlaylists(t.Context(), alice.ID); lists[0].TrackCount != 2 {
t.Errorf("曲目数 = %d,期望 2", lists[0].TrackCount)
}
third := port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香", PageURL: "https://music.163.com/#/song?id=185811"}}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, third, 2); !errors.Is(err, port.ErrPlaylistLimit) {
t.Errorf("歌单满了时 = %v,期望 ErrPlaylistLimit", err)
}
if err := store.AddPlaylistItem(t.Context(), bob.ID, favorites, third, 2); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("加入别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
for range 2 {
if err := store.RemovePlaylistItem(t.Context(), alice.ID, favorites, sunnyItem.Track); err != nil {
t.Fatal(err)
}
}
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || !slices.Equal(items, []port.PlaylistItem{clipItem}) {
t.Errorf("移除之后的曲目 = %#v, %v", items, err)
}
if err := store.RemovePlaylistItem(t.Context(), bob.ID, favorites, clipItem.Track); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("从别人的歌单移除 = %v,期望 ErrPlaylistNotFound", err)
}
}
func TestConcurrentPlaylistAddsRespectLimit(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
favorites := favoritesOf(t, store, alice.ID)
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
item := port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: strconv.Itoa(i)}, Meta: track.Meta{Title: "歌", PageURL: "https://music.163.com/"}}
_ = store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 5)
})
}
wg.Wait()
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || len(items) != 5 {
t.Errorf("并发加入之后有 %d 首, %v,期望正好 5 首", len(items), err)
}
var distinct, seq int64
var largest int64
if err := pool.QueryRow(t.Context(), `SELECT count(DISTINCT i.position), max(i.position), max(p.item_seq)
FROM playlist_items i JOIN playlists p ON p.id = i.playlist_id WHERE i.playlist_id = $1`, favorites).Scan(&distinct, &largest, &seq); err != nil {
t.Fatal(err)
}
if distinct != 5 || largest > seq {
t.Errorf("排序值有 %d 种、最大 %d,歌单的 item_seq 是 %d,期望 5 首的排序值两两不同且都不大于 item_seq", distinct, largest, seq)
}
}
func TestConcurrentPlaylistCreatesRespectLimit(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
var wg sync.WaitGroup
for i := range 10 {
wg.Go(func() {
_, _ = store.CreatePlaylist(t.Context(), alice.ID, "歌单"+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)
}
}
func TestPlaylistTimestamps(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
created, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
if err != nil || created.CreatedAt.IsZero() || created.UpdatedAt.IsZero() || created.Cover != nil {
t.Fatalf("新建 = %#v, %v,期望带创建和更新时间、没有封面", created, err)
}
read := func() port.PlaylistRecord {
t.Helper()
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 2 {
t.Fatalf("歌单 = %#v, %v", lists, err)
}
return lists[1]
}
before := read()
if !before.CreatedAt.Equal(created.CreatedAt) || !before.UpdatedAt.Equal(created.UpdatedAt) {
t.Errorf("读出的时间 %v/%v,期望与新建时一致 %v/%v", before.CreatedAt, before.UpdatedAt, created.CreatedAt, created.UpdatedAt)
}
steps := []struct {
name string
do func() error
changed bool
}{
{"改名", func() error { return store.RenamePlaylist(t.Context(), alice.ID, created.ID, "上班路上") }, true},
{"加入新曲目", func() error { return store.AddPlaylistItem(t.Context(), alice.ID, created.ID, sunnyItem, 10) }, true},
{"重复加入", func() error { return store.AddPlaylistItem(t.Context(), alice.ID, created.ID, sunnyItem, 10) }, false},
{"移除曲目", func() error { return store.RemovePlaylistItem(t.Context(), alice.ID, created.ID, sunnyItem.Track) }, true},
{"移除不存在的曲目", func() error { return store.RemovePlaylistItem(t.Context(), alice.ID, created.ID, sunnyItem.Track) }, false},
}
for _, step := range steps {
if err := step.do(); err != nil {
t.Fatalf("%s:%v", step.name, err)
}
after := read()
if got := after.UpdatedAt.After(before.UpdatedAt); got != step.changed {
t.Errorf("%s之后更新时间推进 = %v,期望 %v", step.name, got, step.changed)
}
if !after.CreatedAt.Equal(before.CreatedAt) {
t.Errorf("%s之后创建时间变了:%v -> %v", step.name, before.CreatedAt, after.CreatedAt)
}
before = after
}
}
func TestPlaylistCoverIsTheFirstSongWithOne(t *testing.T) {
store, _ := newStore(t)
alice := registered(t, store, "alice")
favorites := favoritesOf(t, store, alice.ID)
add := func(source track.Source, id, coverURL string, coverKey track.ObjectKey) {
t.Helper()
item := port.PlaylistItem{
Track: track.Track{Source: source, ID: id},
Meta: track.Meta{Title: id, CoverURL: coverURL, CoverKey: coverKey, PageURL: "https://example.com/" + id},
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 100); err != nil {
t.Fatal(err)
}
}
for i := range 3 {
add(track.NetEase, strconv.Itoa(i), "https://img.example/"+strconv.Itoa(i)+".jpg", "")
}
add(track.Link, "https://example.com/a.mp3", "", "covers/a.jpg")
add(track.NetEase, "no-cover", "", "")
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
if err != nil || len(lists) != 1 {
t.Fatalf("歌单 = %#v, %v", lists, err)
}
if got, want := lists[0].Cover, (port.Cover{Key: "covers/a.jpg"}); got == nil || *got != want {
t.Errorf("封面 = %+v,期望跳过没有封面的歌,取排在最前的 %+v", got, want)
}
}
func TestConcurrentRemovalsAndDeletesDoNotDeadlock(t *testing.T) {
store, pool := newStore(t)
alice := registered(t, store, "alice")
lists := make([]string, 20)
for i := range lists {
p, err := store.CreatePlaylist(t.Context(), alice.ID, "歌单"+strconv.Itoa(i), 50)
if err != nil {
t.Fatal(err)
}
if err := store.AddPlaylistItem(t.Context(), alice.ID, p.ID, sunnyItem, 1000); err != nil {
t.Fatal(err)
}
lists[i] = p.ID
}
removed, deleted := make(chan error, len(lists)), make(chan error, len(lists))
var wg sync.WaitGroup
for _, list := range lists {
wg.Go(func() { removed <- store.RemovePlaylistItem(t.Context(), alice.ID, list, sunnyItem.Track) })
wg.Go(func() { deleted <- store.DeletePlaylist(t.Context(), alice.ID, list) })
}
wg.Wait()
close(removed)
close(deleted)
for err := range removed {
if err != nil && !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("与删除歌单并发移出 = %v,期望成功或 ErrPlaylistNotFound", err)
}
}
for err := range deleted {
if err != nil {
t.Errorf("与移出并发删除歌单 = %v,期望成功", err)
}
}
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)
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ type Members interface {
// name、avatarHash 或 email 为 nil 时保持原值,email 为空字符串表示清除。游客的 email 不保存。
UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error)
AccountProfile(ctx context.Context, memberID string) (AccountProfile, error)
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。同时为这个账号创建“我喜欢”。
Register(ctx context.Context, memberID, username, passwordHash string) error
AccountByUsername(ctx context.Context, username string) (Account, error)
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
+54
View File
@@ -0,0 +1,54 @@
package port
import (
"context"
"errors"
"time"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
ErrPlaylistNotFound = errors.New("歌单不存在")
ErrFavoritesFixed = errors.New("“我喜欢”不能重命名或删除")
ErrPlaylistLimit = errors.New("歌单或歌单里的歌已达上限")
)
type PlaylistRecord struct {
ID string
Name string
Favorites bool
TrackCount int
CreatedAt time.Time
UpdatedAt time.Time
Cover *Cover
}
// 链接曲目的封面在对象存储中,只有对象键。
type Cover struct {
URL string
Key track.ObjectKey
}
type PlaylistItem struct {
Track track.Track
Meta track.Meta
}
// 所有方法都按 ownerID 过滤:别人的歌单和不存在的歌单一样,返回 ErrPlaylistNotFound。“我喜欢”由 Members.Register 创建。
type Playlists interface {
// “我喜欢”在最前,其余按创建的先后。Cover 是歌单里排在最前、有封面的那首歌的封面,没有时为 nil。
MemberPlaylists(ctx context.Context, ownerID string) ([]PlaylistRecord, error)
// 已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (PlaylistRecord, error)
// “我喜欢”返回 ErrFavoritesFixed。
RenamePlaylist(ctx context.Context, ownerID, id, name string) error
// “我喜欢”返回 ErrFavoritesFixed。
DeletePlaylist(ctx context.Context, ownerID, id string) error
// 新加入的在前。
PlaylistItems(ctx context.Context, ownerID, id string) ([]PlaylistItem, error)
// 已经在歌单里时什么也不做;已有 limit 首时返回 ErrPlaylistLimit。
AddPlaylistItem(ctx context.Context, ownerID, id string, item PlaylistItem, limit int) error
// 不在歌单里时什么也不做。
RemovePlaylistItem(ctx context.Context, ownerID, id string, t track.Track) error
}
+162
View File
@@ -16,6 +16,7 @@ import (
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
"github.com/dihemoe/alisten/internal/domain/room"
"github.com/dihemoe/alisten/internal/domain/track"
)
// Store 的行为与 postgres 适配器一致,包括它会拒绝的输入。
@@ -33,6 +34,7 @@ type Store struct {
cookies map[string]string
brandName string
registrationClosed bool
playlists []*storedPlaylist
}
type storedMember struct {
@@ -57,6 +59,7 @@ var (
_ port.Credentials = (*Store)(nil)
_ port.APIKeys = (*Store)(nil)
_ port.Site = (*Store)(nil)
_ port.Playlists = (*Store)(nil)
)
func New() *Store {
@@ -175,6 +178,8 @@ func (s *Store) Register(_ context.Context, memberID, username, passwordHash str
return port.ErrUsernameTaken
}
m.username, m.passwordHash = username, passwordHash
now := time.Now()
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
return nil
}
@@ -526,3 +531,160 @@ func (s *Store) slugOwner(slug string) *port.RoomRecord {
}
return nil
}
type storedPlaylist struct {
id, owner, name string
favorites bool
items []port.PlaylistItem
created, updated time.Time
}
func (p *storedPlaylist) cover() *port.Cover {
for i := len(p.items) - 1; i >= 0; i-- {
if m := p.items[i].Meta; m.CoverURL != "" || m.CoverKey != "" {
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
}
}
return nil
}
// 调用方持有 s.mu。
func (s *Store) playlist(ownerID, id string) *storedPlaylist {
for _, p := range s.playlists {
if p.id == id && p.owner == ownerID {
return p
}
}
return nil
}
func (s *Store) MemberPlaylists(_ context.Context, ownerID string) ([]port.PlaylistRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
var lists []port.PlaylistRecord
for _, p := range s.playlists {
if p.owner != ownerID {
continue
}
r := port.PlaylistRecord{
ID: p.id,
Name: p.name,
Favorites: p.favorites,
TrackCount: len(p.items),
CreatedAt: p.created,
UpdatedAt: p.updated,
Cover: p.cover(),
}
if p.favorites {
lists = append([]port.PlaylistRecord{r}, lists...)
} else {
lists = append(lists, r)
}
}
return lists, nil
}
func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
if err := checkText(name); err != nil {
return port.PlaylistRecord{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, p := range s.playlists {
if p.owner == ownerID {
n++
}
}
if n >= limit {
return port.PlaylistRecord{}, 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
}
func (s *Store) RenamePlaylist(_ context.Context, ownerID, id, name string) error {
if err := checkText(name); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
switch {
case p == nil:
return port.ErrPlaylistNotFound
case p.favorites:
return port.ErrFavoritesFixed
}
p.name = name
p.updated = time.Now()
return nil
}
func (s *Store) DeletePlaylist(_ context.Context, ownerID, id string) error {
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
switch {
case p == nil:
return port.ErrPlaylistNotFound
case p.favorites:
return port.ErrFavoritesFixed
}
s.playlists = slices.DeleteFunc(s.playlists, func(other *storedPlaylist) bool { return other == p })
return nil
}
func (s *Store) PlaylistItems(_ context.Context, ownerID, id string) ([]port.PlaylistItem, error) {
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return nil, port.ErrPlaylistNotFound
}
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
}
func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
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 err
}
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return port.ErrPlaylistNotFound
}
if slices.ContainsFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == item.Track }) {
return nil
}
if len(p.items) >= limit {
return port.ErrPlaylistLimit
}
item.Meta.Duration = item.Meta.Duration.Truncate(time.Millisecond)
p.items = append(p.items, item)
p.updated = time.Now()
return nil
}
func (s *Store) RemovePlaylistItem(_ context.Context, ownerID, id string, t track.Track) error {
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
if p == nil {
return port.ErrPlaylistNotFound
}
n := len(p.items)
p.items = slices.DeleteFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == t })
if len(p.items) < n {
p.updated = time.Now()
}
return nil
}
+33 -1
View File
@@ -1,6 +1,12 @@
package porttest
import "testing"
import (
"testing"
"time"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/track"
)
func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
store := New()
@@ -13,3 +19,29 @@ func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
t.Error("含 NUL 的邮箱应报错")
}
}
func TestAddPlaylistItemKeepsMilliseconds(t *testing.T) {
store := New()
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
if err != nil {
t.Fatal(err)
}
list, err := store.CreatePlaylist(t.Context(), m.ID, "歌单", 10)
if err != nil {
t.Fatal(err)
}
item := port.PlaylistItem{
Track: track.Track{Source: "netease", ID: "1"},
Meta: track.Meta{Title: "歌", Duration: 3*time.Minute + 1234*time.Microsecond + 567*time.Nanosecond},
}
if err := store.AddPlaylistItem(t.Context(), m.ID, list.ID, item, 10); err != nil {
t.Fatal(err)
}
items, err := store.PlaylistItems(t.Context(), m.ID, list.ID)
if err != nil {
t.Fatal(err)
}
if want := 3*time.Minute + time.Millisecond; items[0].Meta.Duration != want {
t.Errorf("Duration = %v,期望 %v", items[0].Meta.Duration, want)
}
}