diff --git a/db/migrations/00004_playlists.sql b/db/migrations/00004_playlists.sql new file mode 100644 index 0000000..a4d5692 --- /dev/null +++ b/db/migrations/00004_playlists.sql @@ -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); diff --git a/db/queries/playlists.sql b/db/queries/playlists.sql new file mode 100644 index 0000000..a6007c2 --- /dev/null +++ b/db/queries/playlists.sql @@ -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); diff --git a/internal/adapter/out/postgres/internal/query/models.go b/internal/adapter/out/postgres/internal/query/models.go index 3561555..7097cd8 100644 --- a/internal/adapter/out/postgres/internal/query/models.go +++ b/internal/adapter/out/postgres/internal/query/models.go @@ -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 diff --git a/internal/adapter/out/postgres/internal/query/playlists.sql.go b/internal/adapter/out/postgres/internal/query/playlists.sql.go new file mode 100644 index 0000000..5aa671b --- /dev/null +++ b/internal/adapter/out/postgres/internal/query/playlists.sql.go @@ -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 +} diff --git a/internal/adapter/out/postgres/members.go b/internal/adapter/out/postgres/members.go index 8a0e15c..961c296 100644 --- a/internal/adapter/out/postgres/members.go +++ b/internal/adapter/out/postgres/members.go @@ -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 } diff --git a/internal/adapter/out/postgres/playlists.go b/internal/adapter/out/postgres/playlists.go new file mode 100644 index 0000000..e45cb40 --- /dev/null +++ b/internal/adapter/out/postgres/playlists.go @@ -0,0 +1,208 @@ +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 { + err := s.owned(ctx, ownerID, id) + if err == nil { + err = s.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 +} diff --git a/internal/adapter/out/postgres/playlists_test.go b/internal/adapter/out/postgres/playlists_test.go new file mode 100644 index 0000000..68af61a --- /dev/null +++ b/internal/adapter/out/postgres/playlists_test.go @@ -0,0 +1,275 @@ +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, _ := 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) + } +} + +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) + } +} diff --git a/internal/app/port/member.go b/internal/app/port/member.go index fbf621d..1c3cc79 100644 --- a/internal/app/port/member.go +++ b/internal/app/port/member.go @@ -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 的会话已经不存在时返回 diff --git a/internal/app/port/playlist.go b/internal/app/port/playlist.go new file mode 100644 index 0000000..9b4f1a6 --- /dev/null +++ b/internal/app/port/playlist.go @@ -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 +} diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go index 2b7f7d4..d765ebd 100644 --- a/internal/app/port/porttest/store.go +++ b/internal/app/port/porttest/store.go @@ -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 { @@ -56,6 +58,7 @@ var ( _ port.Credentials = (*Store)(nil) _ port.APIKeys = (*Store)(nil) _ port.Site = (*Store)(nil) + _ port.Playlists = (*Store)(nil) ) func New() *Store { @@ -174,6 +177,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 } @@ -496,3 +501,159 @@ 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 + } + 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 +}