feat: store playlists with a fixed favorites list for every account
This commit is contained in:
1 parent
f0f38ff511
commit
ffbf28c165
11 files changed
+1263
-7
No files matched your search
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 的会话已经不存在时返回
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user