diff --git a/cmd/protocolgen/godecode.go b/cmd/protocolgen/godecode.go
index b971301..fb4ba80 100644
--- a/cmd/protocolgen/godecode.go
+++ b/cmd/protocolgen/godecode.go
@@ -102,6 +102,8 @@ func (g *goDecoder) object(name string, s *schema) {
at := fmt.Sprintf("field(path, %q)", f)
decode := "decodeString"
switch {
+ case p.Type == "array":
+ decode = fmt.Sprintf("decodeArray(decode%s, %d)", refName(p.Items.Ref), *p.MaxItems)
case p.Ref != "":
decode = "decode" + refName(p.Ref)
case p.Type == "integer":
@@ -149,6 +151,9 @@ func (g *goDecoder) checks(value, at string, s *schema, patternName string) {
g.patterns = append(g.patterns, goPattern{name: name, expr: s.Pattern})
check("checkPattern", name)
}
+ if s.MinItems != nil {
+ check("checkMinItems", fmt.Sprint(*s.MinItems))
+ }
if s.Minimum != nil {
check("checkMinimum", fmt.Sprint(*s.Minimum))
}
diff --git a/cmd/protocolgen/idl.go b/cmd/protocolgen/idl.go
index d59ddd9..d2a6758 100644
--- a/cmd/protocolgen/idl.go
+++ b/cmd/protocolgen/idl.go
@@ -107,6 +107,8 @@ type schema struct {
Required []string `json:"required,omitempty"`
AdditionalProperties *bool `json:"additionalProperties,omitempty"`
Items *schema `json:"items,omitempty"`
+ MinItems *int `json:"minItems,omitempty"`
+ MaxItems *int `json:"maxItems,omitempty"`
AnyOf []*schema `json:"anyOf,omitempty"`
OneOf []*schema `json:"oneOf,omitempty"`
AllOf []*schema `json:"allOf,omitempty"`
@@ -439,21 +441,47 @@ func (d *idl) checkField(where string, s *schema, required, inbound bool) error
}
return d.checkValue(where+".anyOf[0]", s.AnyOf[0], inbound)
case s.Type == "array":
- if inbound || !required {
- return fmt.Errorf("%s:数组只能出现在响应和事件中,并且必须是必填字段", where)
+ if !required {
+ return fmt.Errorf("%s:数组必须是必填字段", where)
}
- if err := allowOnly(where, s, "type", "description", "items"); err != nil {
+ allowed := []string{"type", "description", "items"}
+ if inbound {
+ allowed = append(allowed, "minItems", "maxItems")
+ }
+ if err := allowOnly(where, s, allowed...); err != nil {
return err
}
if s.Items == nil {
return fmt.Errorf("%s:数组缺少 items", where)
}
+ if inbound {
+ if err := d.checkInboundArray(where, s); err != nil {
+ return err
+ }
+ }
return d.checkValue(where+".items", s.Items, inbound)
default:
return d.checkValue(where, s, inbound)
}
}
+// 请求中的数组元素只能引用对象或枚举类型,解码函数按类型名生成。
+func (d *idl) checkInboundArray(where string, s *schema) error {
+ if s.Items.Ref == "" || d.Defs[refName(s.Items.Ref)] != nil && d.Defs[refName(s.Items.Ref)].scalar() {
+ return fmt.Errorf("%s:请求中的数组元素只能引用对象或枚举类型", where)
+ }
+ if s.MaxItems == nil {
+ return fmt.Errorf("%s:请求中的数组必须写 maxItems,解码时先按它拒绝过长的数组", where)
+ }
+ if s.MinItems != nil && *s.MinItems < 0 || *s.MaxItems < 0 {
+ return fmt.Errorf("%s:minItems 和 maxItems 不能是负数", where)
+ }
+ if s.MinItems != nil && *s.MinItems > *s.MaxItems {
+ return fmt.Errorf("%s:minItems 大于 maxItems", where)
+ }
+ return nil
+}
+
func (d *idl) checkValue(where string, s *schema, inbound bool) error {
if s.Ref != "" {
if err := allowOnly(where, s, "$ref", "description"); err != nil {
@@ -962,6 +990,9 @@ func topLevelNames(f *ast.File) []string {
func (d *idl) markInbound(s *schema) error {
for _, name := range slices.Sorted(maps.Keys(s.Properties)) {
p := s.Properties[name]
+ if p.Type == "array" {
+ p = p.Items
+ }
if p.Ref == "" || d.inbound[refName(p.Ref)] || d.Defs[refName(p.Ref)].scalar() {
continue
}
@@ -1055,6 +1086,8 @@ func (s *schema) keywords() []string {
add(s.Required != nil, "required")
add(s.AdditionalProperties != nil, "additionalProperties")
add(s.Items != nil, "items")
+ add(s.MinItems != nil, "minItems")
+ add(s.MaxItems != nil, "maxItems")
add(s.AnyOf != nil, "anyOf")
add(s.OneOf != nil, "oneOf")
add(s.AllOf != nil, "allOf")
diff --git a/cmd/protocolgen/idl_test.go b/cmd/protocolgen/idl_test.go
index 4054032..ec78bba 100644
--- a/cmd/protocolgen/idl_test.go
+++ b/cmd/protocolgen/idl_test.go
@@ -143,17 +143,33 @@ func TestParseIDLRejects(t *testing.T) {
{"required 写了不存在的字段", func(d map[string]any) {
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string"}}, "message", "code")
}, "required 中的 code 不在 properties 里"},
- {"请求中有数组", func(d map[string]any) {
+ {"请求中有标量数组", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "ids"))}
- }, "数组只能出现在响应和事件中"},
- {"请求引用的类型中有数组", func(d map[string]any) {
+ }, "请求中的数组元素只能引用对象或枚举类型"},
+ {"请求引用的类型中有可选数组", func(d map[string]any) {
defs(d)["Batch"] = object(map[string]any{
- "ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
- }, "ids")
+ "ids": map[string]any{"type": "array", "items": ref("Empty")},
+ })
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
- }, "$defs.Batch.properties.ids:数组只能出现在响应和事件中"},
+ }, "$defs.Batch.properties.ids:数组必须是必填字段"},
+ {"请求数组没有 maxItems", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(object(map[string]any{
+ "ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
+ }, "ids"))}
+ }, "请求中的数组必须写 maxItems"},
+ {"请求数组的 minItems 大于 maxItems", func(d map[string]any) {
+ d["x-requests"] = []any{roomRequest(object(map[string]any{
+ "ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 2, "maxItems": 1},
+ }, "ids"))}
+ }, "minItems 大于 maxItems"},
+ {"响应中的数组写了 minItems", func(d map[string]any) {
+ defs(d)["Batch"] = object(map[string]any{
+ "ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
+ }, "ids")
+ d["x-events"] = []any{map[string]any{"type": "batch", "data": ref("Batch")}}
+ }, "不支持 minItems"},
{"生成的类型名重名", func(d map[string]any) {
defs(d)["RoomSyncRequest"] = object(map[string]any{})
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
diff --git a/cmd/protocolgen/testdata/sample/protocol/decode.gen.go b/cmd/protocolgen/testdata/sample/protocol/decode.gen.go
index 7b4734e..d8ce0df 100644
--- a/cmd/protocolgen/testdata/sample/protocol/decode.gen.go
+++ b/cmd/protocolgen/testdata/sample/protocol/decode.gen.go
@@ -48,6 +48,10 @@ func DecodeCreateMemberRequest(body []byte) (CreateMemberRequest, error) {
return decodeCreateMemberRequest(body, "")
}
+func DecodeRemoveTracksRequest(body []byte) (RemoveTracksRequest, error) {
+ return decodeRemoveTracksRequest(body, "")
+}
+
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
return decodeCreateRoomRequest(body, "")
}
@@ -257,6 +261,24 @@ func decodeQueueRemoveRequest(raw json.RawMessage, path string) (QueueRemoveRequ
return v, nil
}
+func decodeRemoveTracksRequest(raw json.RawMessage, path string) (RemoveTracksRequest, error) {
+ var v RemoveTracksRequest
+ m, err := decodeObject(raw, path, "tracks")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "tracks"); err != nil {
+ return v, err
+ }
+ if v.Tracks, err = decodeArray(decodeTrack, 3)(m["tracks"], field(path, "tracks")); err != nil {
+ return v, err
+ }
+ if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
var v RoomSyncRequest
_, err := decodeObject(raw, path)
diff --git a/cmd/protocolgen/testdata/sample/protocol/idl.json b/cmd/protocolgen/testdata/sample/protocol/idl.json
index e812c76..e0b27c2 100644
--- a/cmd/protocolgen/testdata/sample/protocol/idl.json
+++ b/cmd/protocolgen/testdata/sample/protocol/idl.json
@@ -212,6 +212,21 @@
"required": ["member", "token"]
}
},
+ {
+ "name": "removeTracks",
+ "method": "POST",
+ "path": "/api/tracks/removals",
+ "auth": "none",
+ "status": 204,
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 3}
+ },
+ "required": ["tracks"]
+ }
+ },
{
"name": "getRoom",
"method": "GET",
diff --git a/cmd/protocolgen/testdata/sample/protocol/openapi.gen.json b/cmd/protocolgen/testdata/sample/protocol/openapi.gen.json
index 9360e7a..9593deb 100644
--- a/cmd/protocolgen/testdata/sample/protocol/openapi.gen.json
+++ b/cmd/protocolgen/testdata/sample/protocol/openapi.gen.json
@@ -183,6 +183,23 @@
},
"type": "object"
},
+ "RemoveTracksRequest": {
+ "additionalProperties": false,
+ "properties": {
+ "tracks": {
+ "items": {
+ "$ref": "#/components/schemas/Track"
+ },
+ "maxItems": 3,
+ "minItems": 1,
+ "type": "array"
+ }
+ },
+ "required": [
+ "tracks"
+ ],
+ "type": "object"
+ },
"Room": {
"additionalProperties": false,
"description": "房间",
@@ -318,7 +335,7 @@
},
"info": {
"title": "Sample",
- "version": "362d144af81ba8bf"
+ "version": "5e495c1e23fa03a5"
},
"openapi": "3.1.0",
"paths": {
@@ -774,6 +791,37 @@
],
"summary": "座位上的成员,座位号 从 1 开始 & 不超过 8"
}
+ },
+ "/api/tracks/removals": {
+ "post": {
+ "operationId": "removeTracks",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RemoveTracksRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": []
+ }
}
}
}
diff --git a/cmd/protocolgen/testdata/sample/protocol/types.gen.go b/cmd/protocolgen/testdata/sample/protocol/types.gen.go
index b03f45f..df4da6e 100644
--- a/cmd/protocolgen/testdata/sample/protocol/types.gen.go
+++ b/cmd/protocolgen/testdata/sample/protocol/types.gen.go
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
-const Version = "362d144af81ba8bf"
+const Version = "5e495c1e23fa03a5"
type AdminUpdateRoomRequest struct {
Homepage *string `json:"homepage,omitempty"`
@@ -88,6 +88,18 @@ type QueueRemoveRequest struct {
EntryID string `json:"entryId"`
}
+type RemoveTracksRequest struct {
+ Tracks []Track `json:"tracks"`
+}
+
+func (v RemoveTracksRequest) MarshalJSON() ([]byte, error) {
+ type plain RemoveTracksRequest
+ if v.Tracks == nil {
+ v.Tracks = []Track{}
+ }
+ return json.Marshal(plain(v))
+}
+
type Room struct {
Gain float64 `json:"gain"`
ID string `json:"id"`
@@ -154,6 +166,7 @@ const (
var (
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
+ RouteRemoveTracks = Route{Pattern: "POST /api/tracks/removals", Auth: AuthNone, Status: 204}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
RouteGetSeat = Route{Pattern: "GET /api/rooms/{ref}/seats/{seat}", Auth: AuthMember, Status: 200}
diff --git a/cmd/protocolgen/testdata/sample/web/src/core/api/http.gen.ts b/cmd/protocolgen/testdata/sample/web/src/core/api/http.gen.ts
index 966bbad..b28277f 100644
--- a/cmd/protocolgen/testdata/sample/web/src/core/api/http.gen.ts
+++ b/cmd/protocolgen/testdata/sample/web/src/core/api/http.gen.ts
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
-import type { ErrorInfo, Member, Room } from '../realtime/protocol.gen'
+import type { ErrorInfo, Member, Room, Track } from '../realtime/protocol.gen'
export interface AdminUpdateRoomRequest {
homepage?: string
@@ -25,6 +25,10 @@ export interface CreateRoomResponse {
id: string
}
+export interface RemoveTracksRequest {
+ tracks: Track[]
+}
+
export interface ErrorResponse {
error: ErrorInfo
}
@@ -43,6 +47,7 @@ export type HttpSend = (request: HttpRequest) => Promise
export function httpClient(send: HttpSend) {
return {
createMember: () => send({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
+ removeTracks: (body: RemoveTracksRequest) => send({ method: 'POST', path: '/api/tracks/removals', auth: 'none', body }),
getRoom: (ref: string) => send({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
getSeat: (ref: string, seat: string) => send({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
diff --git a/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.ts b/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.ts
index 640a91a..484d514 100644
--- a/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.ts
+++ b/cmd/protocolgen/testdata/sample/web/src/core/realtime/protocol.gen.ts
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
-export const PROTOCOL_VERSION = '362d144af81ba8bf'
+export const PROTOCOL_VERSION = '5e495c1e23fa03a5'
export type Empty = Record
diff --git a/db/queries/playlists.sql b/db/queries/playlists.sql
index 701490c..dbfc854 100644
--- a/db/queries/playlists.sql
+++ b/db/queries/playlists.sql
@@ -56,9 +56,45 @@ 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
+-- name: DeletePlaylistItems :exec
WITH deleted AS (
- DELETE FROM playlist_items WHERE playlist_id = @playlist_id AND source = @source AND track_id = @track_id
- RETURNING playlist_id
+ DELETE FROM playlist_items i
+ USING unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
+ JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS t(track_id, ord) USING (ord)
+ WHERE i.playlist_id = @playlist_id AND i.source = s.source AND i.track_id = t.track_id
+ RETURNING i.playlist_id
)
UPDATE playlists SET updated_at = now() WHERE id IN (SELECT playlist_id FROM deleted);
+
+-- name: CopyPlaylistItems :execrows
+-- 按请求的先后复制:第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
+WITH wanted AS (
+ SELECT s.source, k.track_id, min(s.ord) AS ord
+ FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
+ JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
+ GROUP BY s.source, k.track_id
+), copied AS (
+ SELECT i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
+ row_number() OVER (ORDER BY w.ord DESC) AS rank
+ FROM wanted w
+ JOIN playlist_items i ON i.playlist_id = @from_id AND i.source = w.source AND i.track_id = w.track_id
+ WHERE NOT EXISTS (
+ SELECT FROM playlist_items d WHERE d.playlist_id = @to_id AND d.source = w.source AND d.track_id = w.track_id
+ )
+)
+INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
+SELECT sqlc.arg(to_id), source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, sqlc.arg(item_seq)::bigint + rank
+FROM copied;
+
+-- name: PlaylistTracks :many
+SELECT source, track_id FROM playlist_items WHERE playlist_id = @playlist_id ORDER BY position DESC;
+
+-- name: SetPlaylistPositions :exec
+UPDATE playlist_items i SET position = t.position
+FROM unnest(@sources::text[]) WITH ORDINALITY AS s(source, ord)
+JOIN unnest(@track_ids::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
+JOIN unnest(@positions::bigint[]) WITH ORDINALITY AS t(position, ord) USING (ord)
+WHERE i.playlist_id = @playlist_id AND i.source = s.source AND i.track_id = k.track_id;
+
+-- name: TouchPlaylist :exec
+UPDATE playlists SET updated_at = now() WHERE id = @id;
diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go
index 7be4436..7da7213 100644
--- a/internal/adapter/in/api/api.go
+++ b/internal/adapter/in/api/api.go
@@ -26,14 +26,17 @@ import (
"github.com/dihemoe/alisten/internal/app/realtime"
"github.com/dihemoe/alisten/internal/app/site"
"github.com/dihemoe/alisten/internal/domain/member"
+ playlistorder "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/room"
"github.com/dihemoe/alisten/internal/lib/logctx"
"github.com/dihemoe/alisten/protocol"
)
const (
- maxBodyBytes = 64 << 10
- requestDeadline = 3 * time.Minute
+ maxBodyBytes = 64 << 10
+ // 歌单的批量接口一次最多 1000 首,链接曲目的 ID 最长 2048 字节,每首再留 256 字节给来源、字段名和 JSON 的标点。
+ maxBatchBodyBytes = 1000 * (2048 + 256)
+ requestDeadline = 3 * time.Minute
)
type Services struct {
@@ -113,7 +116,9 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
srv.handle(mux, protocol.RouteDeleteMyPlaylist, srv.deleteMyPlaylist)
srv.handle(mux, protocol.RouteListMyPlaylistItems, srv.listMyPlaylistItems)
srv.handle(mux, protocol.RouteAddMyPlaylistItem, srv.addMyPlaylistItem)
- srv.handle(mux, protocol.RouteRemoveMyPlaylistItem, srv.removeMyPlaylistItem)
+ srv.handleLimit(mux, protocol.RouteRemoveMyPlaylistItems, maxBatchBodyBytes, srv.removeMyPlaylistItems)
+ srv.handleLimit(mux, protocol.RouteCopyMyPlaylistItems, maxBatchBodyBytes, srv.copyMyPlaylistItems)
+ srv.handleLimit(mux, protocol.RouteMoveMyPlaylistItems, maxBatchBodyBytes, srv.moveMyPlaylistItems)
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -170,10 +175,14 @@ func SameOrigin(allowedOrigins []string, next http.Handler) http.Handler {
}
func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc) {
+ s.handleLimit(mux, route, maxBodyBytes, fn)
+}
+
+func (s *server) handleLimit(mux *http.ServeMux, route protocol.Route, maxBody int64, fn handlerFunc) {
mux.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), requestDeadline)
defer cancel()
- ctx, resp, err := s.serve(ctx, w, r, route.Auth, func(ctx context.Context, c call) (any, error) {
+ ctx, resp, err := s.serve(ctx, w, r, route.Auth, maxBody, func(ctx context.Context, c call) (any, error) {
resp, err := fn(ctx, c)
if err != nil || !route.Location {
return resp, err
@@ -196,7 +205,7 @@ func (s *server) handle(mux *http.ServeMux, route protocol.Route, fn handlerFunc
// 先鉴权再读请求体:后台没有启用时,无论请求是否合法都返回 FORBIDDEN。返回的 ctx 带有凭证所属的成员和房间,
// 出错时的日志也要带上它们。
-func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, fn handlerFunc) (logCtx context.Context, _ any, err error) {
+func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Request, auth protocol.Auth, maxBody int64, fn handlerFunc) (logCtx context.Context, _ any, err error) {
defer func() {
if value := recover(); value != nil {
logCtx, err = ctx, s.panicked(ctx, r, value)
@@ -229,7 +238,7 @@ func (s *server) serve(ctx context.Context, w http.ResponseWriter, r *http.Reque
return ctx, nil, fmt.Errorf("不支持的凭证类型 %d", auth)
}
if r.Method != http.MethodGet && r.Method != http.MethodDelete {
- body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
return ctx, nil, bodyError(r, err)
}
@@ -316,6 +325,8 @@ var knownErrors = []struct {
{playlist.ErrPlaylistFull, protocol.ErrorCodeConflict},
{playlist.ErrTrackNotReady, protocol.ErrorCodeConflict},
{playlist.ErrTooFast, protocol.ErrorCodeRateLimited},
+ {playlistorder.ErrBeforeMoved, protocol.ErrorCodeInvalidArgument},
+ {playlistorder.ErrBeforeMissing, protocol.ErrorCodeConflict},
{realtime.ErrRequesterNotAllowed, protocol.ErrorCodeInvalidArgument},
{site.ErrInvalidBrandName, protocol.ErrorCodeInvalidArgument},
{realtime.ErrUnavailable, protocol.ErrorCodeUnavailable},
@@ -334,7 +345,7 @@ func errorInfo(err error) (info protocol.ErrorInfo, expected bool) {
}
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
- return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: "请求体不能超过 64 KiB"}, true
+ return protocol.ErrorInfo{Code: protocol.ErrorCodeInvalidArgument, Message: fmt.Sprintf("请求体不能超过 %d KiB", tooLarge.Limit>>10)}, true
}
var roomErr *room.Error
if errors.As(err, &roomErr) {
diff --git a/internal/adapter/in/api/api_test.go b/internal/adapter/in/api/api_test.go
index b3f60c4..f21af2b 100644
--- a/internal/adapter/in/api/api_test.go
+++ b/internal/adapter/in/api/api_test.go
@@ -22,6 +22,7 @@ import (
"github.com/dihemoe/alisten/internal/app/admin"
"github.com/dihemoe/alisten/internal/app/identity"
"github.com/dihemoe/alisten/internal/app/lobby"
+ "github.com/dihemoe/alisten/internal/app/playlist"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -154,6 +155,54 @@ func TestRequestBodyLimit(t *testing.T) {
}
}
+func TestPlaylistBatchBodyLimit(t *testing.T) {
+ store := porttest.New()
+ s := services(store, store, "")
+ s.Playlists = playlist.New(store, store, noTracks{}, s.Catalog, nil)
+ server, _ := start(t, s)
+ var created protocol.CreateMemberResponse
+ if err := json.Unmarshal([]byte(send(t, server, http.MethodPost, "/api/members", "{}").body), &created); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Register(t.Context(), created.Member.ID, "alice", "hash"); err != nil {
+ t.Fatal(err)
+ }
+ auth := []string{"Authorization", "Bearer " + created.MemberToken}
+ lists, err := store.MemberPlaylists(t.Context(), created.Member.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ favorites := lists[0].ID
+ other, err := store.CreatePlaylist(t.Context(), created.Member.ID, "通勤", 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ tracks := make([]string, 1000)
+ for i := range tracks {
+ tracks[i] = fmt.Sprintf(`{"source":"link","id":"https://www.youtube.com/watch?v=%011d&list=PL%032d"}`, i, i)
+ }
+ list := "[" + strings.Join(tracks, ",") + "]"
+ if len(list) <= 64<<10 {
+ t.Fatalf("1000 首链接只有 %d 字节,测不出 64 KiB 的上限", len(list))
+ }
+ if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
+ t.Errorf("移出 1000 首链接 = %#v,期望成功", got)
+ }
+ if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/moves", `{"tracks":`+list+`}`, auth...); got.status != http.StatusNoContent {
+ t.Errorf("移动 1000 首链接 = %#v,期望成功", got)
+ }
+ if got := send(t, server, http.MethodPost, "/api/me/playlists/"+other.ID+"/copies", `{"from":"`+favorites+`","tracks":`+list+`}`, auth...); got != (response{status: http.StatusOK, body: `{"added":0}`}) {
+ t.Errorf("复制 1000 首链接 = %#v,期望成功", got)
+ }
+
+ tooLarge := `{"tracks":[]}` + strings.Repeat(" ", maxBatchBodyBytes)
+ want := failure(protocol.ErrorCodeInvalidArgument, fmt.Sprintf("请求体不能超过 %d KiB", maxBatchBodyBytes>>10))
+ if got := send(t, server, http.MethodPost, "/api/me/playlists/"+favorites+"/removals", tooLarge, auth...); got != want {
+ t.Errorf("超过批量上限的请求体 = %#v,期望 %#v", got, want)
+ }
+}
+
func TestMalformedPathParametersAreNotFound(t *testing.T) {
store := porttest.New()
server, logs := start(t, services(store, store, ""))
diff --git a/internal/adapter/in/api/events.go b/internal/adapter/in/api/events.go
index b8cd29f..9d76018 100644
--- a/internal/adapter/in/api/events.go
+++ b/internal/adapter/in/api/events.go
@@ -41,7 +41,7 @@ type streamClose struct {
func (s *server) roomEvents(w http.ResponseWriter, r *http.Request) {
// 打开事件流的期限与 WebSocket 的握手相同,打开之后的推送不设期限。
setup, cancel := context.WithTimeout(r.Context(), helloTimeout)
- ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, s.observe)
+ ctx, opened, err := s.serve(setup, w, r, protocol.AuthTicket, maxBodyBytes, s.observe)
cancel()
if err != nil {
s.fail(ctx, w, r, err)
diff --git a/internal/adapter/in/api/playlist.go b/internal/adapter/in/api/playlist.go
index 5b390b5..b291c5f 100644
--- a/internal/adapter/in/api/playlist.go
+++ b/internal/adapter/in/api/playlist.go
@@ -4,6 +4,7 @@ import (
"context"
"github.com/dihemoe/alisten/internal/app/port"
+ "github.com/dihemoe/alisten/internal/domain/track"
"github.com/dihemoe/alisten/protocol"
)
@@ -91,10 +92,43 @@ func (s *server) addMyPlaylistItem(ctx context.Context, c call) (any, error) {
return protocol.AddMyPlaylistItemResponse{Item: s.playlistItemDTO(item)}, nil
}
-func (s *server) removeMyPlaylistItem(ctx context.Context, c call) (any, error) {
- req, err := protocol.DecodeRemoveMyPlaylistItemRequest(c.body)
+func tracksOf(in []protocol.Track) []track.Track {
+ out := make([]track.Track, len(in))
+ for i, t := range in {
+ out[i] = trackOf(t)
+ }
+ return out
+}
+
+func (s *server) removeMyPlaylistItems(ctx context.Context, c call) (any, error) {
+ req, err := protocol.DecodeRemoveMyPlaylistItemsRequest(c.body)
if err != nil {
return nil, err
}
- return nil, s.Playlists.Remove(ctx, c.member.ID, c.r.PathValue("id"), trackOf(req.Track))
+ return nil, s.Playlists.Remove(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks))
+}
+
+func (s *server) copyMyPlaylistItems(ctx context.Context, c call) (any, error) {
+ req, err := protocol.DecodeCopyMyPlaylistItemsRequest(c.body)
+ if err != nil {
+ return nil, err
+ }
+ n, err := s.Playlists.Copy(ctx, c.member.ID, req.From, c.r.PathValue("id"), tracksOf(req.Tracks))
+ if err != nil {
+ return nil, err
+ }
+ return protocol.CopyMyPlaylistItemsResponse{Added: int64(n)}, nil
+}
+
+func (s *server) moveMyPlaylistItems(ctx context.Context, c call) (any, error) {
+ req, err := protocol.DecodeMoveMyPlaylistItemsRequest(c.body)
+ if err != nil {
+ return nil, err
+ }
+ var before *track.Track
+ if req.Before != nil {
+ t := trackOf(*req.Before)
+ before = &t
+ }
+ return nil, s.Playlists.Move(ctx, c.member.ID, c.r.PathValue("id"), tracksOf(req.Tracks), before)
}
diff --git a/internal/adapter/out/postgres/internal/query/playlists.sql.go b/internal/adapter/out/postgres/internal/query/playlists.sql.go
index db4c8f8..4ee48df 100644
--- a/internal/adapter/out/postgres/internal/query/playlists.sql.go
+++ b/internal/adapter/out/postgres/internal/query/playlists.sql.go
@@ -10,6 +10,49 @@ import (
"time"
)
+const copyPlaylistItems = `-- name: CopyPlaylistItems :execrows
+WITH wanted AS (
+ SELECT s.source, k.track_id, min(s.ord) AS ord
+ FROM unnest($3::text[]) WITH ORDINALITY AS s(source, ord)
+ JOIN unnest($4::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
+ GROUP BY s.source, k.track_id
+), copied AS (
+ SELECT i.source, i.track_id, i.title, i.artist, i.album, i.cover_url, i.cover_key, i.duration_ms, i.page_url,
+ row_number() OVER (ORDER BY w.ord DESC) AS rank
+ FROM wanted w
+ JOIN playlist_items i ON i.playlist_id = $5 AND i.source = w.source AND i.track_id = w.track_id
+ WHERE NOT EXISTS (
+ SELECT FROM playlist_items d WHERE d.playlist_id = $1 AND d.source = w.source AND d.track_id = w.track_id
+ )
+)
+INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
+SELECT $1, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, $2::bigint + rank
+FROM copied
+`
+
+type CopyPlaylistItemsParams struct {
+ ToID string
+ ItemSeq int64
+ Sources []string
+ TrackIds []string
+ FromID string
+}
+
+// 按请求的先后复制:第一首的排序值最大。请求里重复的歌只取第一次出现的位置。
+func (q *Queries) CopyPlaylistItems(ctx context.Context, arg CopyPlaylistItemsParams) (int64, error) {
+ result, err := q.db.Exec(ctx, copyPlaylistItems,
+ arg.ToID,
+ arg.ItemSeq,
+ arg.Sources,
+ arg.TrackIds,
+ arg.FromID,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
const countPlaylistItems = `-- name: CountPlaylistItems :one
SELECT count(*) FROM playlist_items WHERE playlist_id = $1
`
@@ -81,22 +124,25 @@ func (q *Queries) DeletePlaylist(ctx context.Context, arg DeletePlaylistParams)
return result.RowsAffected(), nil
}
-const deletePlaylistItem = `-- name: DeletePlaylistItem :exec
+const deletePlaylistItems = `-- name: DeletePlaylistItems :exec
WITH deleted AS (
- DELETE FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3
- RETURNING playlist_id
+ DELETE FROM playlist_items i
+ USING unnest($1::text[]) WITH ORDINALITY AS s(source, ord)
+ JOIN unnest($2::text[]) WITH ORDINALITY AS t(track_id, ord) USING (ord)
+ WHERE i.playlist_id = $3 AND i.source = s.source AND i.track_id = t.track_id
+ RETURNING i.playlist_id
)
UPDATE playlists SET updated_at = now() WHERE id IN (SELECT playlist_id FROM deleted)
`
-type DeletePlaylistItemParams struct {
+type DeletePlaylistItemsParams struct {
+ Sources []string
+ TrackIds []string
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)
+func (q *Queries) DeletePlaylistItems(ctx context.Context, arg DeletePlaylistItemsParams) error {
+ _, err := q.db.Exec(ctx, deletePlaylistItems, arg.Sources, arg.TrackIds, arg.PlaylistID)
return err
}
@@ -312,6 +358,35 @@ func (q *Queries) PlaylistKind(ctx context.Context, arg PlaylistKindParams) (str
return kind, err
}
+const playlistTracks = `-- name: PlaylistTracks :many
+SELECT source, track_id FROM playlist_items WHERE playlist_id = $1 ORDER BY position DESC
+`
+
+type PlaylistTracksRow struct {
+ Source string
+ TrackID string
+}
+
+func (q *Queries) PlaylistTracks(ctx context.Context, playlistID string) ([]PlaylistTracksRow, error) {
+ rows, err := q.db.Query(ctx, playlistTracks, playlistID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []PlaylistTracksRow
+ for rows.Next() {
+ var i PlaylistTracksRow
+ if err := rows.Scan(&i.Source, &i.TrackID); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const renamePlaylist = `-- name: RenamePlaylist :execrows
UPDATE playlists SET name = $1, updated_at = now() WHERE id = $2 AND owner_id = $3 AND kind = 'custom'
`
@@ -330,6 +405,31 @@ func (q *Queries) RenamePlaylist(ctx context.Context, arg RenamePlaylistParams)
return result.RowsAffected(), nil
}
+const setPlaylistPositions = `-- name: SetPlaylistPositions :exec
+UPDATE playlist_items i SET position = t.position
+FROM unnest($2::text[]) WITH ORDINALITY AS s(source, ord)
+JOIN unnest($3::text[]) WITH ORDINALITY AS k(track_id, ord) USING (ord)
+JOIN unnest($4::bigint[]) WITH ORDINALITY AS t(position, ord) USING (ord)
+WHERE i.playlist_id = $1 AND i.source = s.source AND i.track_id = k.track_id
+`
+
+type SetPlaylistPositionsParams struct {
+ PlaylistID string
+ Sources []string
+ TrackIds []string
+ Positions []int64
+}
+
+func (q *Queries) SetPlaylistPositions(ctx context.Context, arg SetPlaylistPositionsParams) error {
+ _, err := q.db.Exec(ctx, setPlaylistPositions,
+ arg.PlaylistID,
+ arg.Sources,
+ arg.TrackIds,
+ arg.Positions,
+ )
+ return err
+}
+
const setPlaylistSeq = `-- name: SetPlaylistSeq :exec
UPDATE playlists SET item_seq = $1, updated_at = now() WHERE id = $2
`
@@ -343,3 +443,12 @@ func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams)
_, err := q.db.Exec(ctx, setPlaylistSeq, arg.ItemSeq, arg.ID)
return err
}
+
+const touchPlaylist = `-- name: TouchPlaylist :exec
+UPDATE playlists SET updated_at = now() WHERE id = $1
+`
+
+func (q *Queries) TouchPlaylist(ctx context.Context, id string) error {
+ _, err := q.db.Exec(ctx, touchPlaylist, id)
+ return err
+}
diff --git a/internal/adapter/out/postgres/playlists.go b/internal/adapter/out/postgres/playlists.go
index f345809..d3e05e8 100644
--- a/internal/adapter/out/postgres/playlists.go
+++ b/internal/adapter/out/postgres/playlists.go
@@ -4,12 +4,14 @@ import (
"context"
"errors"
"fmt"
+ "slices"
"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/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
@@ -196,7 +198,7 @@ func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item po
return nil
}
-func (s *Store) RemovePlaylistItem(ctx context.Context, ownerID, id string, t track.Track) error {
+func (s *Store) RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error {
if !isUUID(id) {
return port.ErrPlaylistNotFound
}
@@ -207,10 +209,96 @@ func (s *Store) RemovePlaylistItem(ctx context.Context, ownerID, id string, t tr
} else if err != nil {
return err
}
- return q.DeletePlaylistItem(ctx, query.DeletePlaylistItemParams{PlaylistID: id, Source: string(t.Source), TrackID: t.ID})
+ sources, ids := splitTracks(tracks)
+ return q.DeletePlaylistItems(ctx, query.DeletePlaylistItemsParams{PlaylistID: id, Sources: sources, TrackIds: ids})
})
if err != nil {
- return fmt.Errorf("从歌单 %s 移除 %s/%s:%w", id, t.Source, t.ID, err)
+ return fmt.Errorf("从歌单 %s 移除 %d 首歌:%w", id, len(tracks), err)
}
return nil
}
+
+func (s *Store) CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) {
+ var added int64
+ err := s.owned(ctx, ownerID, fromID)
+ if err == nil && !isUUID(toID) {
+ err = port.ErrPlaylistNotFound
+ }
+ if err == nil {
+ err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
+ q := s.q.WithTx(tx)
+ seq, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: toID, OwnerID: ownerID})
+ if errors.Is(err, pgx.ErrNoRows) {
+ return port.ErrPlaylistNotFound
+ }
+ if err != nil {
+ return err
+ }
+ sources, ids := splitTracks(tracks)
+ added, err = q.CopyPlaylistItems(ctx, query.CopyPlaylistItemsParams{FromID: fromID, ToID: toID, ItemSeq: seq, Sources: sources, TrackIds: ids})
+ if err != nil || added == 0 {
+ return err
+ }
+ n, err := q.CountPlaylistItems(ctx, toID)
+ if err != nil {
+ return err
+ }
+ if n > int64(limit) {
+ return port.ErrPlaylistLimit
+ }
+ return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: toID, ItemSeq: seq + added})
+ })
+ }
+ if err != nil {
+ return 0, fmt.Errorf("从歌单 %s 向歌单 %s 复制 %d 首歌:%w", fromID, toID, len(tracks), err)
+ }
+ return int(added), nil
+}
+
+func (s *Store) MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *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
+ }
+ rows, err := q.PlaylistTracks(ctx, id)
+ if err != nil {
+ return err
+ }
+ order := make([]track.Track, len(rows))
+ for i, r := range rows {
+ order[i] = track.Track{Source: track.Source(r.Source), ID: r.TrackID}
+ }
+ moved, err := playlist.Move(order, tracks, before)
+ if err != nil || slices.Equal(moved, order) {
+ return err
+ }
+ // 显示顺序里最靠前的取最大的排序值;歌单的 item_seq 不小于曲目数,所以新加入的歌仍然排在最前。
+ positions := make([]int64, len(moved))
+ for i := range moved {
+ positions[i] = int64(len(moved) - i)
+ }
+ sources, ids := splitTracks(moved)
+ if err := q.SetPlaylistPositions(ctx, query.SetPlaylistPositionsParams{PlaylistID: id, Sources: sources, TrackIds: ids, Positions: positions}); err != nil {
+ return err
+ }
+ return q.TouchPlaylist(ctx, id)
+ })
+ if err != nil {
+ return fmt.Errorf("调整歌单 %s 的顺序:%w", id, err)
+ }
+ return nil
+}
+
+func splitTracks(tracks []track.Track) (sources, ids []string) {
+ sources, ids = make([]string, len(tracks)), make([]string, len(tracks))
+ for i, t := range tracks {
+ sources[i], ids[i] = string(t.Source), t.ID
+ }
+ return sources, ids
+}
diff --git a/internal/adapter/out/postgres/playlists_test.go b/internal/adapter/out/postgres/playlists_test.go
index e929381..5554130 100644
--- a/internal/adapter/out/postgres/playlists_test.go
+++ b/internal/adapter/out/postgres/playlists_test.go
@@ -12,6 +12,7 @@ import (
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
+ playlistorder "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
@@ -157,14 +158,14 @@ func TestPlaylistItems(t *testing.T) {
t.Errorf("加入别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
}
for range 2 {
- if err := store.RemovePlaylistItem(t.Context(), alice.ID, favorites, sunnyItem.Track); err != nil {
+ if err := store.RemovePlaylistItems(t.Context(), alice.ID, favorites, []track.Track{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) {
+ if err := store.RemovePlaylistItems(t.Context(), bob.ID, favorites, []track.Track{clipItem.Track}); !errors.Is(err, port.ErrPlaylistNotFound) {
t.Errorf("从别人的歌单移除 = %v,期望 ErrPlaylistNotFound", err)
}
}
@@ -237,8 +238,12 @@ func TestPlaylistTimestamps(t *testing.T) {
{"改名", 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},
+ {"移除曲目", func() error {
+ return store.RemovePlaylistItems(t.Context(), alice.ID, created.ID, []track.Track{sunnyItem.Track})
+ }, true},
+ {"移除不存在的曲目", func() error {
+ return store.RemovePlaylistItems(t.Context(), alice.ID, created.ID, []track.Track{sunnyItem.Track})
+ }, false},
}
for _, step := range steps {
if err := step.do(); err != nil {
@@ -283,24 +288,206 @@ func TestPlaylistCoverIsTheFirstSongWithOne(t *testing.T) {
}
}
+func songItem(id string) port.PlaylistItem {
+ return port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: id}, Meta: track.Meta{Title: "歌" + id, Artist: "艺人", CoverURL: "https://example.com/" + id + ".jpg", PageURL: "https://music.163.com/#/song?id=" + id, Duration: time.Minute}}
+}
+
+func displayIDs(t *testing.T, store *Store, ownerID, id string) []string {
+ t.Helper()
+ items, err := store.PlaylistItems(t.Context(), ownerID, id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ids := make([]string, len(items))
+ for i, item := range items {
+ ids[i] = item.Track.ID
+ }
+ return ids
+}
+
+// 依次加入 ids,显示顺序与 ids 相反。
+func filled(t *testing.T, store *Store, ownerID, name string, ids ...string) string {
+ t.Helper()
+ p, err := store.CreatePlaylist(t.Context(), ownerID, name, 50)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range ids {
+ if err := store.AddPlaylistItem(t.Context(), ownerID, p.ID, songItem(id), 1000); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return p.ID
+}
+
+func updatedAt(t *testing.T, store *Store, ownerID, id string) time.Time {
+ t.Helper()
+ lists, err := store.MemberPlaylists(t.Context(), ownerID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, l := range lists {
+ if l.ID == id {
+ return l.UpdatedAt
+ }
+ }
+ t.Fatalf("没有歌单 %s", id)
+ return time.Time{}
+}
+
+func trackList(ids ...string) []track.Track {
+ out := make([]track.Track, len(ids))
+ for i, id := range ids {
+ out[i] = track.Track{Source: track.NetEase, ID: id}
+ }
+ return out
+}
+
+func TestRemovePlaylistItemsIgnoresMissingTracks(t *testing.T) {
+ store, _ := newStore(t)
+ alice := registered(t, store, "alice")
+ list := filled(t, store, alice.ID, "通勤", "1", "2", "3", "4")
+ if err := store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList("2", "9", "4", "2")); err != nil {
+ t.Fatal(err)
+ }
+ if got := displayIDs(t, store, alice.ID, list); !slices.Equal(got, []string{"3", "1"}) {
+ t.Errorf("批量移出之后 = %v,期望 [3 1]", got)
+ }
+}
+
+func TestCopyPlaylistItems(t *testing.T) {
+ store, _ := newStore(t)
+ alice := registered(t, store, "alice")
+ bob := registered(t, store, "bob")
+ from := filled(t, store, alice.ID, "来源", "1", "2", "3", "4")
+ to := filled(t, store, alice.ID, "目标", "3", "9")
+ before := updatedAt(t, store, alice.ID, to)
+
+ n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("4", "x", "3", "1", "4"), 1000)
+ if err != nil || n != 2 {
+ t.Fatalf("复制 = %d, %v,期望跳过已有的、不存在的和重复的,复制 2 首", n, err)
+ }
+ if got := displayIDs(t, store, alice.ID, to); !slices.Equal(got, []string{"4", "1", "9", "3"}) {
+ t.Errorf("复制之后 = %v,期望新复制的在最前且保持给定的先后 [4 1 9 3]", got)
+ }
+ items, _ := store.PlaylistItems(t.Context(), alice.ID, to)
+ if want := songItem("4"); items[0] != want {
+ t.Errorf("复制的曲目 = %#v,期望带着元数据快照 %#v", items[0], want)
+ }
+ if !updatedAt(t, store, alice.ID, to).After(before) {
+ t.Error("复制之后目标歌单的更新时间没有推进")
+ }
+
+ if err := store.AddPlaylistItem(t.Context(), alice.ID, to, songItem("5"), 1000); err != nil {
+ t.Fatal(err)
+ }
+ if got := displayIDs(t, store, alice.ID, to); got[0] != "5" {
+ t.Errorf("复制之后新加入的歌 = %v,期望仍在最前", got)
+ }
+
+ before = updatedAt(t, store, alice.ID, to)
+ if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("1", "x"), 1000); err != nil || n != 0 {
+ t.Errorf("没有可复制的歌 = %d, %v,期望 0", n, err)
+ }
+ if !updatedAt(t, store, alice.ID, to).Equal(before) {
+ t.Error("没有复制任何歌,更新时间却变了")
+ }
+
+ if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("2"), 5); !errors.Is(err, port.ErrPlaylistLimit) || n != 0 {
+ t.Errorf("装不下时 = %d, %v,期望 ErrPlaylistLimit", n, err)
+ }
+ if n, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, to, trackList("2"), 6); err != nil || n != 1 {
+ t.Errorf("正好装满时 = %d, %v,期望复制 1 首", n, err)
+ }
+
+ full := filled(t, store, alice.ID, "满", "1", "2")
+ if _, err := store.CopyPlaylistItems(t.Context(), alice.ID, from, full, trackList("3", "4", "1"), 3); !errors.Is(err, port.ErrPlaylistLimit) {
+ t.Fatalf("超限 = %v,期望 ErrPlaylistLimit", err)
+ }
+ if got := displayIDs(t, store, alice.ID, full); !slices.Equal(got, []string{"2", "1"}) {
+ t.Errorf("整批拒绝之后 = %v,期望一首也没有复制", got)
+ }
+
+ other := filled(t, store, bob.ID, "别人的", "7")
+ for name, call := range map[string]func() (int, error){
+ "来源是别人的": func() (int, error) {
+ return store.CopyPlaylistItems(t.Context(), alice.ID, other, to, trackList("7"), 1000)
+ },
+ "目标是别人的": func() (int, error) {
+ return store.CopyPlaylistItems(t.Context(), alice.ID, from, other, trackList("1"), 1000)
+ },
+ "来源不是 UUID": func() (int, error) {
+ return store.CopyPlaylistItems(t.Context(), alice.ID, "x", to, trackList("1"), 1000)
+ },
+ "目标不是 UUID": func() (int, error) {
+ return store.CopyPlaylistItems(t.Context(), alice.ID, from, "x", trackList("1"), 1000)
+ },
+ } {
+ if n, err := call(); !errors.Is(err, port.ErrPlaylistNotFound) || n != 0 {
+ t.Errorf("%s = %d, %v,期望 ErrPlaylistNotFound", name, n, err)
+ }
+ }
+}
+
+func TestConcurrentCrossCopiesDoNotDeadlock(t *testing.T) {
+ store, _ := newStore(t)
+ alice := registered(t, store, "alice")
+ a := filled(t, store, alice.ID, "甲", "1", "2")
+ b := filled(t, store, alice.ID, "乙", "3", "4")
+ var wg sync.WaitGroup
+ for range 20 {
+ wg.Go(func() { _, _ = store.CopyPlaylistItems(t.Context(), alice.ID, a, b, trackList("1", "2"), 1000) })
+ wg.Go(func() { _, _ = store.CopyPlaylistItems(t.Context(), alice.ID, b, a, trackList("3", "4"), 1000) })
+ }
+ wg.Wait()
+ for _, id := range []string{a, b} {
+ if got := displayIDs(t, store, alice.ID, id); len(got) != 4 {
+ t.Errorf("互相复制之后有 %v,期望各 4 首", got)
+ }
+ }
+}
+
+func TestConcurrentRemovalsAndMovesDoNotDeadlock(t *testing.T) {
+ store, _ := newStore(t)
+ alice := registered(t, store, "alice")
+ ids := make([]string, 40)
+ for i := range ids {
+ ids[i] = strconv.Itoa(i)
+ }
+ list := filled(t, store, alice.ID, "通勤", ids...)
+ errs := make(chan error, 40)
+ var wg sync.WaitGroup
+ for i := range 20 {
+ wg.Go(func() { errs <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[i])) })
+ wg.Go(func() { errs <- store.MovePlaylistItems(t.Context(), alice.ID, list, trackList(ids[20+i]), nil) })
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ if err != nil {
+ t.Errorf("并发移出和移动 = %v,期望都成功", err)
+ }
+ }
+ got := displayIDs(t, store, alice.ID, list)
+ slices.Sort(got)
+ want := slices.Clone(ids[20:])
+ slices.Sort(want)
+ if !slices.Equal(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
+ lists[i] = filled(t, store, alice.ID, "歌单"+strconv.Itoa(i), "1", "2", "3")
}
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() { removed <- store.RemovePlaylistItems(t.Context(), alice.ID, list, trackList("1", "2", "3")) })
wg.Go(func() { deleted <- store.DeletePlaylist(t.Context(), alice.ID, list) })
}
wg.Wait()
@@ -324,3 +511,78 @@ func TestConcurrentRemovalsAndDeletesDoNotDeadlock(t *testing.T) {
t.Errorf("删除歌单之后还有 %d 首曲目, %v,期望 0", count, err)
}
}
+
+func TestMovePlaylistItems(t *testing.T) {
+ store, _ := newStore(t)
+ alice := registered(t, store, "alice")
+ bob := registered(t, store, "bob")
+ list := filled(t, store, alice.ID, "通勤", "5", "4", "3", "2", "1")
+ at := func(id string) *track.Track { return &trackList(id)[0] }
+ steps := []struct {
+ name string
+ moved []track.Track
+ before *track.Track
+ want []string
+ }{
+ {"移到最前", trackList("4"), at("1"), []string{"4", "1", "2", "3", "5"}},
+ {"移到最后", trackList("4"), nil, []string{"1", "2", "3", "5", "4"}},
+ {"移到中间", trackList("1"), at("5"), []string{"2", "3", "1", "5", "4"}},
+ {"多首保持给定的先后", trackList("4", "2"), at("3"), []string{"4", "2", "3", "1", "5"}},
+ {"忽略不在歌单里的歌", trackList("x", "5"), at("2"), []string{"4", "5", "2", "3", "1"}},
+ }
+ for _, step := range steps {
+ if err := store.MovePlaylistItems(t.Context(), alice.ID, list, step.moved, step.before); err != nil {
+ t.Fatalf("%s:%v", step.name, err)
+ }
+ if got := displayIDs(t, store, alice.ID, list); !slices.Equal(got, step.want) {
+ t.Errorf("%s之后 = %v,期望 %v", step.name, got, step.want)
+ }
+ }
+
+ if err := store.AddPlaylistItem(t.Context(), alice.ID, list, songItem("6"), 1000); err != nil {
+ t.Fatal(err)
+ }
+ if got := displayIDs(t, store, alice.ID, list); got[0] != "6" {
+ t.Errorf("移动之后新加入的歌 = %v,期望仍在最前", got)
+ }
+
+ errs := []struct {
+ name string
+ moved []track.Track
+ before *track.Track
+ want error
+ }{
+ {"before 是被移动的歌", trackList("1", "2"), at("2"), playlistorder.ErrBeforeMoved},
+ {"before 不在歌单里", trackList("1"), at("x"), playlistorder.ErrBeforeMissing},
+ }
+ for _, e := range errs {
+ if err := store.MovePlaylistItems(t.Context(), alice.ID, list, e.moved, e.before); !errors.Is(err, e.want) {
+ t.Errorf("%s = %v,期望 %v", e.name, err, e.want)
+ }
+ }
+ if err := store.MovePlaylistItems(t.Context(), bob.ID, list, trackList("1"), nil); !errors.Is(err, port.ErrPlaylistNotFound) {
+ t.Errorf("移动别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
+ }
+ if err := store.MovePlaylistItems(t.Context(), alice.ID, "not-a-uuid", trackList("1"), nil); !errors.Is(err, port.ErrPlaylistNotFound) {
+ t.Errorf("移动不存在的歌单 = %v,期望 ErrPlaylistNotFound", err)
+ }
+}
+
+func TestMovePlaylistItemsAdvancesUpdatedAt(t *testing.T) {
+ store, _ := newStore(t)
+ alice := registered(t, store, "alice")
+ list := filled(t, store, alice.ID, "通勤", "3", "2", "1")
+ before := updatedAt(t, store, alice.ID, list)
+ if err := store.MovePlaylistItems(t.Context(), alice.ID, list, trackList("x"), nil); err != nil {
+ t.Fatal(err)
+ }
+ if !updatedAt(t, store, alice.ID, list).Equal(before) {
+ t.Error("没有歌被移动,更新时间却变了")
+ }
+ if err := store.MovePlaylistItems(t.Context(), alice.ID, list, trackList("1"), nil); err != nil {
+ t.Fatal(err)
+ }
+ if !updatedAt(t, store, alice.ID, list).After(before) {
+ t.Error("移动之后更新时间没有推进")
+ }
+}
diff --git a/internal/app/playlist/playlist.go b/internal/app/playlist/playlist.go
index cf76620..41ed90d 100644
--- a/internal/app/playlist/playlist.go
+++ b/internal/app/playlist/playlist.go
@@ -117,11 +117,29 @@ func (s *Service) Add(ctx context.Context, memberID, id string, t track.Track) (
return item, nil
}
-func (s *Service) Remove(ctx context.Context, memberID, id string, t track.Track) error {
+func (s *Service) Remove(ctx context.Context, memberID, id string, tracks []track.Track) error {
if err := s.registered(ctx, memberID); err != nil {
return err
}
- return s.playlists.RemovePlaylistItem(ctx, memberID, id, t)
+ return s.playlists.RemovePlaylistItems(ctx, memberID, id, tracks)
+}
+
+func (s *Service) Copy(ctx context.Context, memberID, fromID, toID string, tracks []track.Track) (int, error) {
+ if err := s.registered(ctx, memberID); err != nil {
+ return 0, err
+ }
+ n, err := s.playlists.CopyPlaylistItems(ctx, memberID, fromID, toID, tracks, maxItems)
+ if errors.Is(err, port.ErrPlaylistLimit) {
+ return 0, ErrPlaylistFull
+ }
+ return n, err
+}
+
+func (s *Service) Move(ctx context.Context, memberID, id string, tracks []track.Track, before *track.Track) error {
+ if err := s.registered(ctx, memberID); err != nil {
+ return err
+ }
+ return s.playlists.MovePlaylistItems(ctx, memberID, id, tracks, before)
}
func (s *Service) registered(ctx context.Context, memberID string) error {
diff --git a/internal/app/playlist/playlist_test.go b/internal/app/playlist/playlist_test.go
index 471454a..9f2a3f1 100644
--- a/internal/app/playlist/playlist_test.go
+++ b/internal/app/playlist/playlist_test.go
@@ -14,6 +14,7 @@ import (
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/port/porttest"
"github.com/dihemoe/alisten/internal/domain/member"
+ playlistorder "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/track"
)
@@ -118,6 +119,15 @@ func TestGuestCannotUsePlaylists(t *testing.T) {
if _, err := f.svc.Add(t.Context(), guest.ID, "any", sunny); !errors.Is(err, ErrNotRegistered) {
t.Errorf("游客收藏 = %v,期望 ErrNotRegistered", err)
}
+ if err := f.svc.Remove(t.Context(), guest.ID, "any", []track.Track{sunny}); !errors.Is(err, ErrNotRegistered) {
+ t.Errorf("游客批量移出 = %v,期望 ErrNotRegistered", err)
+ }
+ if _, err := f.svc.Copy(t.Context(), guest.ID, "from", "to", []track.Track{sunny}); !errors.Is(err, ErrNotRegistered) {
+ t.Errorf("游客复制 = %v,期望 ErrNotRegistered", err)
+ }
+ if err := f.svc.Move(t.Context(), guest.ID, "any", []track.Track{sunny}, nil); !errors.Is(err, ErrNotRegistered) {
+ t.Errorf("游客调整顺序 = %v,期望 ErrNotRegistered", err)
+ }
if len(f.quota.keys) != 0 {
t.Errorf("游客不应消耗额度:%v", f.quota.keys)
}
@@ -170,7 +180,7 @@ func TestAddTakesMetaFromTheServer(t *testing.T) {
if want := []string{"playlist:member:" + alice.ID}; !slices.Equal(f.quota.keys, want) {
t.Errorf("取的额度 = %v,期望 %v", f.quota.keys, want)
}
- if err := f.svc.Remove(t.Context(), alice.ID, favorites, sunny); err != nil {
+ if err := f.svc.Remove(t.Context(), alice.ID, favorites, []track.Track{sunny}); err != nil {
t.Fatal(err)
}
if items, _ := f.svc.Items(t.Context(), alice.ID, favorites); len(items) != 0 {
@@ -211,6 +221,10 @@ func (fullPlaylists) AddPlaylistItem(context.Context, string, string, port.Playl
return port.ErrPlaylistLimit
}
+func (fullPlaylists) CopyPlaylistItems(context.Context, string, string, string, []track.Track, int) (int, error) {
+ return 0, port.ErrPlaylistLimit
+}
+
func TestAddReportsFullPlaylist(t *testing.T) {
f := newFixture(t)
alice := f.member(t, "alice")
@@ -232,3 +246,45 @@ func TestFavoritesStayFixed(t *testing.T) {
t.Errorf("删除“我喜欢” = %v,期望 ErrFavoritesFixed", err)
}
}
+
+func TestCopyReportsFullPlaylist(t *testing.T) {
+ f := newFixture(t)
+ alice := f.member(t, "alice")
+ favorites := f.favorites(t, alice.ID)
+ commute, err := f.svc.Create(t.Context(), alice.ID, "通勤")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := f.svc.Copy(t.Context(), alice.ID, favorites, commute.ID, []track.Track{sunny}); err != nil || n != 1 {
+ t.Errorf("复制 = %d, %v,期望 1", n, err)
+ }
+ if len(f.quota.keys) != 1 {
+ t.Errorf("复制不应取收藏额度:%v", f.quota.keys)
+ }
+ f.svc.playlists = fullPlaylists{f.store}
+ if _, err := f.svc.Copy(t.Context(), alice.ID, favorites, commute.ID, []track.Track{sunny}); !errors.Is(err, ErrPlaylistFull) {
+ t.Errorf("歌单满了时 = %v,期望 ErrPlaylistFull", err)
+ }
+}
+
+func TestMoveReordersAndReportsDomainErrors(t *testing.T) {
+ f := newFixture(t)
+ alice := f.member(t, "alice")
+ favorites := f.favorites(t, alice.ID)
+ if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.svc.Move(t.Context(), alice.ID, favorites, []track.Track{sunny}, &sunny); !errors.Is(err, playlistorder.ErrBeforeMoved) {
+ t.Errorf("before 是被移动的歌 = %v,期望 ErrBeforeMoved", err)
+ }
+ missing := track.Track{Source: track.NetEase, ID: "1"}
+ if err := f.svc.Move(t.Context(), alice.ID, favorites, []track.Track{sunny}, &missing); !errors.Is(err, playlistorder.ErrBeforeMissing) {
+ t.Errorf("before 不在歌单里 = %v,期望 ErrBeforeMissing", err)
+ }
+ if err := f.svc.Move(t.Context(), alice.ID, favorites, []track.Track{sunny}, nil); err != nil {
+ t.Errorf("移到最后 = %v", err)
+ }
+}
diff --git a/internal/app/port/playlist.go b/internal/app/port/playlist.go
index 9b4f1a6..ecf8302 100644
--- a/internal/app/port/playlist.go
+++ b/internal/app/port/playlist.go
@@ -49,6 +49,12 @@ type Playlists interface {
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
+ // 不在歌单里的歌忽略。
+ RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error
+ // 把 fromID 歌单里的这些歌连同元数据复制到 toID 歌单,返回复制的数量。fromID 不属于 ownerID 时返回 ErrPlaylistNotFound。
+ // 不在 fromID 里的和已在 toID 里的跳过;新复制的排在最前,tracks[0] 最靠前。
+ // 复制之后超过 limit 首时整批拒绝,返回 ErrPlaylistLimit。
+ CopyPlaylistItems(ctx context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error)
+ // 按 playlist.Move 的规则调整顺序,错误原样返回。
+ MovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error
}
diff --git a/internal/app/port/porttest/store.go b/internal/app/port/porttest/store.go
index 0edb8e2..d59b94e 100644
--- a/internal/app/port/porttest/store.go
+++ b/internal/app/port/porttest/store.go
@@ -15,6 +15,7 @@ import (
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/domain/member"
+ "github.com/dihemoe/alisten/internal/domain/playlist"
"github.com/dihemoe/alisten/internal/domain/room"
"github.com/dihemoe/alisten/internal/domain/track"
)
@@ -661,7 +662,7 @@ func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port
return nil
}
-func (s *Store) RemovePlaylistItem(_ context.Context, ownerID, id string, t track.Track) error {
+func (s *Store) RemovePlaylistItems(_ context.Context, ownerID, id string, tracks []track.Track) error {
s.mu.Lock()
defer s.mu.Unlock()
p := s.playlist(ownerID, id)
@@ -669,9 +670,63 @@ func (s *Store) RemovePlaylistItem(_ context.Context, ownerID, id string, t trac
return port.ErrPlaylistNotFound
}
n := len(p.items)
- p.items = slices.DeleteFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == t })
+ p.items = slices.DeleteFunc(p.items, func(i port.PlaylistItem) bool { return slices.Contains(tracks, i.Track) })
if len(p.items) < n {
p.updated = time.Now()
}
return nil
}
+
+func (s *Store) CopyPlaylistItems(_ context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ from, to := s.playlist(ownerID, fromID), s.playlist(ownerID, toID)
+ if from == nil || to == nil {
+ return 0, port.ErrPlaylistNotFound
+ }
+ var fresh []port.PlaylistItem
+ for _, t := range tracks {
+ i := slices.IndexFunc(from.items, func(i port.PlaylistItem) bool { return i.Track == t })
+ if i < 0 || slices.ContainsFunc(to.items, func(i port.PlaylistItem) bool { return i.Track == t }) ||
+ slices.ContainsFunc(fresh, func(i port.PlaylistItem) bool { return i.Track == t }) {
+ continue
+ }
+ fresh = append(fresh, from.items[i])
+ }
+ if len(fresh) == 0 {
+ return 0, nil
+ }
+ if len(to.items)+len(fresh) > limit {
+ return 0, port.ErrPlaylistLimit
+ }
+ slices.Reverse(fresh)
+ to.items = append(to.items, fresh...)
+ to.updated = time.Now()
+ return len(fresh), nil
+}
+
+func (s *Store) MovePlaylistItems(_ context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ p := s.playlist(ownerID, id)
+ if p == nil {
+ return port.ErrPlaylistNotFound
+ }
+ order := make([]track.Track, len(p.items))
+ for i, item := range p.items {
+ order[len(order)-1-i] = item.Track
+ }
+ moved, err := playlist.Move(order, tracks, before)
+ if err != nil || slices.Equal(moved, order) {
+ return err
+ }
+ byTrack := make(map[track.Track]port.PlaylistItem, len(p.items))
+ for _, item := range p.items {
+ byTrack[item.Track] = item
+ }
+ for i, t := range moved {
+ p.items[len(moved)-1-i] = byTrack[t]
+ }
+ p.updated = time.Now()
+ return nil
+}
diff --git a/internal/bootstrap/media_test.go b/internal/bootstrap/media_test.go
index 9949c10..1d22a0e 100644
--- a/internal/bootstrap/media_test.go
+++ b/internal/bootstrap/media_test.go
@@ -238,7 +238,7 @@ func TestPlaylistLinkTracks(t *testing.T) {
t.Errorf("歌单里的曲目 = %+v,期望一首 ID %s、封面 %s", items.Items, link, coverURL)
}
- call("POST /api/me/playlists/"+favorites+"/removals"+auth+"\n\n"+`{"track": {"source": "link", "id": "hTTps://example.COM/Song.mp3"}}`, http.StatusNoContent, nil)
+ call("POST /api/me/playlists/"+favorites+"/removals"+auth+"\n\n"+`{"tracks": [{"source": "link", "id": "hTTps://example.COM/Song.mp3"}]}`, http.StatusNoContent, nil)
call("GET /api/me/playlists/"+favorites+"/items"+auth, http.StatusOK, &items)
if len(items.Items) != 0 {
t.Errorf("换一种大小写移出之后还有 %+v,期望没有", items.Items)
diff --git a/internal/bootstrap/testdata/http/playlists.txtar b/internal/bootstrap/testdata/http/playlists.txtar
index 3acdc65..5c502ae 100644
--- a/internal/bootstrap/testdata/http/playlists.txtar
+++ b/internal/bootstrap/testdata/http/playlists.txtar
@@ -1,4 +1,4 @@
-歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;别人的歌单和不存在的一样。
+歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;可以批量移出、复制到别的歌单、调整顺序;别人的歌单和不存在的一样。
-- request --
POST /api/members
@@ -128,18 +128,109 @@ Authorization: Bearer $alice
409
{"error": {"code": "CONFLICT", "message": "“我喜欢”不能重命名或删除"}}
+-- request --
+POST /api/me/playlists/$commute/copies
+Authorization: Bearer $alice
+
+{"from": "$favorites", "tracks": [{"source": "netease", "id": "186016"}, {"source": "netease", "id": "185811"}, {"source": "netease", "id": "999"}, {"source": "netease", "id": "186016"}]}
+-- response --
+200
+{"added": 2}
+
+-- request --
+POST /api/me/playlists/$commute/copies
+Authorization: Bearer $alice
+
+{"from": "$favorites", "tracks": [{"source": "netease", "id": "185811"}]}
+-- response --
+200
+{"added": 0}
+
+-- request --
+GET /api/me/playlists/$commute/items
+Authorization: Bearer $alice
+-- response --
+200
+{"items": [
+ {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}},
+ {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}
+]}
+
+-- request --
+POST /api/me/playlists/$commute/moves
+Authorization: Bearer $alice
+
+{"tracks": [{"source": "netease", "id": "186016"}]}
+-- response --
+204
+
+-- request --
+GET /api/me/playlists/$commute/items
+Authorization: Bearer $alice
+-- response --
+200
+{"items": [
+ {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}},
+ {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}}
+]}
+
+-- request --
+POST /api/me/playlists/$commute/moves
+Authorization: Bearer $alice
+
+{"tracks": [{"source": "netease", "id": "186016"}], "before": {"source": "netease", "id": "185811"}}
+-- response --
+204
+
+-- request --
+GET /api/me/playlists/$commute/items
+Authorization: Bearer $alice
+-- response --
+200
+{"items": [
+ {"track": {"source": "netease", "id": "186016"}, "meta": {"title": "晴天", "artist": "周杰伦", "album": "叶惠美", "coverUrl": "https://p1.music.126.net/example.jpg", "pageUrl": "https://music.163.com/#/song?id=186016", "durationMs": 269000}},
+ {"track": {"source": "netease", "id": "185811"}, "meta": {"title": "七里香", "artist": "周杰伦", "album": "七里香", "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "pageUrl": "https://music.163.com/#/song?id=185811", "durationMs": 299000}}
+]}
+
+-- request --
+POST /api/me/playlists/$commute/moves
+Authorization: Bearer $alice
+
+{"tracks": [{"source": "netease", "id": "186016"}], "before": {"source": "netease", "id": "999"}}
+-- response --
+409
+{"error": {"code": "CONFLICT", "message": "歌单已经变了,刷新之后再试"}}
+
+-- request --
+POST /api/me/playlists/$commute/moves
+Authorization: Bearer $alice
+
+{"tracks": [{"source": "netease", "id": "186016"}], "before": {"source": "netease", "id": "186016"}}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "before 不能是要移动的歌"}}
+
+-- request --
+POST /api/me/playlists/$commute/moves
+Authorization: Bearer $alice
+
+{"tracks": []}
+-- response --
+400
+{"error": {"code": "INVALID_ARGUMENT", "message": "tracks:元素不能少于 1 个"}}
+
-- request --
GET /api/me/playlists
Authorization: Bearer $alice
-- response --
200
-{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
+{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}]}
-- request --
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $alice
-{"track": {"source": "netease", "id": "186016"}}
+{"tracks": [{"source": "netease", "id": "186016"}, {"source": "netease", "id": "999"}]}
-- response --
204
@@ -147,7 +238,7 @@ Authorization: Bearer $alice
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $alice
-{"track": {"source": "netease", "id": "186016"}}
+{"tracks": [{"source": "netease", "id": "186016"}, {"source": "netease", "id": "999"}]}
-- response --
204
@@ -175,6 +266,40 @@ Authorization: Bearer $bob
201
{"account": {"username": "bob"}}
+-- request --
+GET /api/me/playlists
+Authorization: Bearer $bob
+-- response --
+200
+{"items": [{"id": "$bobFavorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
+
+-- request --
+POST /api/me/playlists/$bobFavorites/copies
+Authorization: Bearer $bob
+
+{"from": "$favorites", "tracks": [{"source": "netease", "id": "185811"}]}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
+
+-- request --
+POST /api/me/playlists/$favorites/copies
+Authorization: Bearer $bob
+
+{"from": "$bobFavorites", "tracks": [{"source": "netease", "id": "185811"}]}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
+
+-- request --
+POST /api/me/playlists/$favorites/moves
+Authorization: Bearer $bob
+
+{"tracks": [{"source": "netease", "id": "185811"}]}
+-- response --
+404
+{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
+
-- request --
PATCH /api/me/playlists/$favorites
Authorization: Bearer $bob
@@ -197,7 +322,7 @@ Authorization: Bearer $bob
POST /api/me/playlists/$favorites/removals
Authorization: Bearer $bob
-{"track": {"source": "netease", "id": "185811"}}
+{"tracks": [{"source": "netease", "id": "185811"}]}
-- response --
404
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
diff --git a/internal/domain/playlist/order.go b/internal/domain/playlist/order.go
new file mode 100644
index 0000000..d29289a
--- /dev/null
+++ b/internal/domain/playlist/order.go
@@ -0,0 +1,44 @@
+package playlist
+
+import (
+ "errors"
+ "slices"
+
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+var (
+ ErrBeforeMoved = errors.New("before 不能是要移动的歌")
+ ErrBeforeMissing = errors.New("歌单已经变了,刷新之后再试")
+)
+
+// order 是显示顺序;返回移动后的显示顺序。moved 里不在 order 中的歌忽略,重复的只算一次;
+// 都不在 order 中时原样返回,此时不检查 before 是否还在。
+func Move(order []track.Track, moved []track.Track, before *track.Track) ([]track.Track, error) {
+ if before != nil && slices.Contains(moved, *before) {
+ return nil, ErrBeforeMoved
+ }
+ var block []track.Track
+ for _, t := range moved {
+ if slices.Contains(order, t) && !slices.Contains(block, t) {
+ block = append(block, t)
+ }
+ }
+ if len(block) == 0 {
+ return slices.Clone(order), nil
+ }
+ rest := make([]track.Track, 0, len(order)-len(block))
+ for _, t := range order {
+ if !slices.Contains(block, t) {
+ rest = append(rest, t)
+ }
+ }
+ at := len(rest)
+ if before != nil {
+ at = slices.Index(rest, *before)
+ if at < 0 {
+ return nil, ErrBeforeMissing
+ }
+ }
+ return slices.Concat(rest[:at], block, rest[at:]), nil
+}
diff --git a/internal/domain/playlist/order_test.go b/internal/domain/playlist/order_test.go
new file mode 100644
index 0000000..1c3a3ad
--- /dev/null
+++ b/internal/domain/playlist/order_test.go
@@ -0,0 +1,60 @@
+package playlist
+
+import (
+ "errors"
+ "slices"
+ "testing"
+
+ "github.com/dihemoe/alisten/internal/domain/track"
+)
+
+func tr(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
+
+func trs(ids ...string) []track.Track {
+ out := make([]track.Track, len(ids))
+ for i, id := range ids {
+ out[i] = tr(id)
+ }
+ return out
+}
+
+func TestMove(t *testing.T) {
+ order := trs("a", "b", "c", "d", "e")
+ at := func(id string) *track.Track { t := tr(id); return &t }
+ tests := []struct {
+ name string
+ moved []track.Track
+ before *track.Track
+ want []track.Track
+ wantErr error
+ }{
+ {"移到最前", trs("d"), at("a"), trs("d", "a", "b", "c", "e"), nil},
+ {"移到最后", trs("b"), nil, trs("a", "c", "d", "e", "b"), nil},
+ {"移到中间", trs("a"), at("d"), trs("b", "c", "a", "d", "e"), nil},
+ {"多首保持给定先后", trs("e", "b"), at("d"), trs("a", "c", "e", "b", "d"), nil},
+ {"多首移到最后保持给定先后", trs("c", "a"), nil, trs("b", "d", "e", "c", "a"), nil},
+ {"忽略不存在的歌", trs("x", "c", "y"), at("a"), trs("c", "a", "b", "d", "e"), nil},
+ {"重复的歌只算一次", trs("c", "c", "b"), nil, trs("a", "d", "e", "c", "b"), nil},
+ {"before 紧跟在移动的歌之后", trs("b"), at("c"), order, nil},
+ {"before 是被移动的歌", trs("b", "c"), at("c"), nil, ErrBeforeMoved},
+ {"before 是不存在的被移动的歌", trs("x"), at("x"), nil, ErrBeforeMoved},
+ {"before 不存在", trs("b"), at("x"), nil, ErrBeforeMissing},
+ {"空移动", nil, at("c"), order, nil},
+ {"移动的歌都不存在", trs("x"), at("y"), order, nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ before := slices.Clone(order)
+ got, err := Move(order, tt.moved, tt.before)
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("err = %v,期望 %v", err, tt.wantErr)
+ }
+ if !slices.Equal(got, tt.want) {
+ t.Errorf("Move = %v,期望 %v", got, tt.want)
+ }
+ if !slices.Equal(order, before) {
+ t.Errorf("order 被修改:%v", order)
+ }
+ })
+ }
+}
diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go
index c704057..fa4a6bf 100644
--- a/protocol/decode.gen.go
+++ b/protocol/decode.gen.go
@@ -98,8 +98,16 @@ func DecodeAddMyPlaylistItemRequest(body []byte) (AddMyPlaylistItemRequest, erro
return decodeAddMyPlaylistItemRequest(body, "")
}
-func DecodeRemoveMyPlaylistItemRequest(body []byte) (RemoveMyPlaylistItemRequest, error) {
- return decodeRemoveMyPlaylistItemRequest(body, "")
+func DecodeRemoveMyPlaylistItemsRequest(body []byte) (RemoveMyPlaylistItemsRequest, error) {
+ return decodeRemoveMyPlaylistItemsRequest(body, "")
+}
+
+func DecodeCopyMyPlaylistItemsRequest(body []byte) (CopyMyPlaylistItemsRequest, error) {
+ return decodeCopyMyPlaylistItemsRequest(body, "")
+}
+
+func DecodeMoveMyPlaylistItemsRequest(body []byte) (MoveMyPlaylistItemsRequest, error) {
+ return decodeMoveMyPlaylistItemsRequest(body, "")
}
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
@@ -450,6 +458,27 @@ func decodeChatSendRequest(raw json.RawMessage, path string) (ChatSendRequest, e
return v, nil
}
+func decodeCopyMyPlaylistItemsRequest(raw json.RawMessage, path string) (CopyMyPlaylistItemsRequest, error) {
+ var v CopyMyPlaylistItemsRequest
+ m, err := decodeObject(raw, path, "from", "tracks")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "from", "tracks"); err != nil {
+ return v, err
+ }
+ if v.From, err = decodeString(m["from"], field(path, "from")); err != nil {
+ return v, err
+ }
+ if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
+ return v, err
+ }
+ if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
func decodeCreateMemberRequest(raw json.RawMessage, path string) (CreateMemberRequest, error) {
var v CreateMemberRequest
_, err := decodeObject(raw, path)
@@ -572,6 +601,31 @@ func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error)
return v, nil
}
+func decodeMoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (MoveMyPlaylistItemsRequest, error) {
+ var v MoveMyPlaylistItemsRequest
+ m, err := decodeObject(raw, path, "before", "tracks")
+ if err != nil {
+ return v, err
+ }
+ if err = requireFields(m, path, "tracks"); err != nil {
+ return v, err
+ }
+ if r, ok := m["before"]; ok {
+ x, err := decodeTrack(r, field(path, "before"))
+ if err != nil {
+ return v, err
+ }
+ v.Before = &x
+ }
+ if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
+ return v, err
+ }
+ if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
+ return v, err
+ }
+ return v, nil
+}
+
func decodePingRequest(raw json.RawMessage, path string) (PingRequest, error) {
var v PingRequest
m, err := decodeObject(raw, path, "clientTimeMs")
@@ -750,16 +804,19 @@ func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, e
return v, nil
}
-func decodeRemoveMyPlaylistItemRequest(raw json.RawMessage, path string) (RemoveMyPlaylistItemRequest, error) {
- var v RemoveMyPlaylistItemRequest
- m, err := decodeObject(raw, path, "track")
+func decodeRemoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (RemoveMyPlaylistItemsRequest, error) {
+ var v RemoveMyPlaylistItemsRequest
+ m, err := decodeObject(raw, path, "tracks")
if err != nil {
return v, err
}
- if err = requireFields(m, path, "track"); err != nil {
+ if err = requireFields(m, path, "tracks"); err != nil {
return v, err
}
- if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil {
+ if v.Tracks, err = decodeArray(decodeTrack, 1000)(m["tracks"], field(path, "tracks")); err != nil {
+ return v, err
+ }
+ if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
return v, nil
diff --git a/protocol/decode.go b/protocol/decode.go
index d866092..0d3450c 100644
--- a/protocol/decode.go
+++ b/protocol/decode.go
@@ -88,6 +88,35 @@ func decodeInteger(raw json.RawMessage, path string) (int64, error) {
return n, nil
}
+// 先数元素再逐个解码:过长的数组不必解码就能拒绝。
+func decodeArray[T any](decode func(json.RawMessage, string) (T, error), maxItems int) func(json.RawMessage, string) ([]T, error) {
+ return func(raw json.RawMessage, path string) ([]T, error) {
+ var elems []json.RawMessage
+ if bytes.Equal(raw, null) || json.Unmarshal(raw, &elems) != nil {
+ return nil, invalid(path, "应为数组")
+ }
+ if len(elems) > maxItems {
+ return nil, invalid(path, fmt.Sprintf("元素不能超过 %d 个", maxItems))
+ }
+ out := make([]T, len(elems))
+ for i, e := range elems {
+ v, err := decode(e, fmt.Sprintf("%s[%d]", path, i))
+ if err != nil {
+ return nil, err
+ }
+ out[i] = v
+ }
+ return out, nil
+ }
+}
+
+func checkMinItems[T any](a []T, path string, n int) error {
+ if len(a) < n {
+ return invalid(path, fmt.Sprintf("元素不能少于 %d 个", n))
+ }
+ return nil
+}
+
func checkMinLength(s, path string, n int) error {
if utf8.RuneCountInString(s) < n {
return invalid(path, fmt.Sprintf("长度不能少于 %d 个字符", n))
diff --git a/protocol/decode_test.go b/protocol/decode_test.go
index db244b5..e8884da 100644
--- a/protocol/decode_test.go
+++ b/protocol/decode_test.go
@@ -3,6 +3,7 @@ package protocol
import (
"encoding/json"
"errors"
+ "strings"
"testing"
)
@@ -67,3 +68,29 @@ func TestCheckOneOf(t *testing.T) {
}
}
}
+
+func TestDecodeTrackArray(t *testing.T) {
+ one := `{"source":"netease","id":"1"}`
+ tests := []struct {
+ body string
+ want string
+ }{
+ {`{"tracks":[` + one + `]}`, ""},
+ {`{"tracks":[]}`, "tracks:元素不能少于 1 个"},
+ {`{"tracks":null}`, "tracks:应为数组"},
+ {`{"tracks":[{"source":"netease"}]}`, "tracks[0].id:缺少必填字段"},
+ {`{}`, "tracks:缺少必填字段"},
+ }
+ for _, tt := range tests {
+ _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tt.body))
+ if got := errText(err); got != tt.want {
+ t.Errorf("%s = %q,期望 %q", tt.body, got, tt.want)
+ }
+ }
+ for _, elem := range []string{one, "{}"} {
+ tooMany := `{"tracks":[` + elem + strings.Repeat(","+elem, 1000) + `]}`
+ if got := errText(func() error { _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tooMany)); return err }()); got != "tracks:元素不能超过 1000 个" {
+ t.Errorf("1001 个 %s = %q,期望先按个数拒绝、不再解码元素", elem, got)
+ }
+ }
+}
diff --git a/protocol/idl.json b/protocol/idl.json
index 1008a2b..67090c2 100644
--- a/protocol/idl.json
+++ b/protocol/idl.json
@@ -923,8 +923,8 @@
}
},
{
- "name": "removeMyPlaylistItem",
- "description": "把一首歌移出歌单;不在歌单里也算成功",
+ "name": "removeMyPlaylistItems",
+ "description": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略",
"method": "POST",
"path": "/api/me/playlists/{id}/removals",
"auth": "member",
@@ -933,9 +933,50 @@
"type": "object",
"additionalProperties": false,
"properties": {
- "track": {"$ref": "#/$defs/Track"}
+ "tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000}
},
- "required": ["track"]
+ "required": ["tracks"]
+ }
+ },
+ {
+ "name": "copyMyPlaylistItems",
+ "description": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首;不在来源歌单里的和已在这个歌单里的跳过;新复制的排在最前,保持 tracks 的先后;装不下时整批拒绝;返回实际复制的数量",
+ "method": "POST",
+ "path": "/api/me/playlists/{id}/copies",
+ "auth": "member",
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "from": {"type": "string", "description": "来源歌单的 ID"},
+ "tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000}
+ },
+ "required": ["from", "tracks"]
+ },
+ "response": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "added": {"type": "integer"}
+ },
+ "required": ["added"]
+ }
+ },
+ {
+ "name": "moveMyPlaylistItems",
+ "description": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后;一次 1 到 1000 首;before 不能在 tracks 里,也必须还在歌单里",
+ "method": "POST",
+ "path": "/api/me/playlists/{id}/moves",
+ "auth": "member",
+ "status": 204,
+ "request": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000},
+ "before": {"$ref": "#/$defs/Track"}
+ },
+ "required": ["tracks"]
}
},
{
diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json
index ede2beb..21221cf 100644
--- a/protocol/openapi.gen.json
+++ b/protocol/openapi.gen.json
@@ -441,6 +441,40 @@
],
"type": "object"
},
+ "CopyMyPlaylistItemsRequest": {
+ "additionalProperties": false,
+ "properties": {
+ "from": {
+ "description": "来源歌单的 ID",
+ "type": "string"
+ },
+ "tracks": {
+ "items": {
+ "$ref": "#/components/schemas/Track"
+ },
+ "maxItems": 1000,
+ "minItems": 1,
+ "type": "array"
+ }
+ },
+ "required": [
+ "from",
+ "tracks"
+ ],
+ "type": "object"
+ },
+ "CopyMyPlaylistItemsResponse": {
+ "additionalProperties": false,
+ "properties": {
+ "added": {
+ "type": "integer"
+ }
+ },
+ "required": [
+ "added"
+ ],
+ "type": "object"
+ },
"CreateMemberRequest": {
"additionalProperties": false,
"type": "object"
@@ -806,6 +840,26 @@
],
"type": "object"
},
+ "MoveMyPlaylistItemsRequest": {
+ "additionalProperties": false,
+ "properties": {
+ "before": {
+ "$ref": "#/components/schemas/Track"
+ },
+ "tracks": {
+ "items": {
+ "$ref": "#/components/schemas/Track"
+ },
+ "maxItems": 1000,
+ "minItems": 1,
+ "type": "array"
+ }
+ },
+ "required": [
+ "tracks"
+ ],
+ "type": "object"
+ },
"MyPlaylist": {
"additionalProperties": false,
"properties": {
@@ -1178,15 +1232,20 @@
],
"type": "object"
},
- "RemoveMyPlaylistItemRequest": {
+ "RemoveMyPlaylistItemsRequest": {
"additionalProperties": false,
"properties": {
- "track": {
- "$ref": "#/components/schemas/Track"
+ "tracks": {
+ "items": {
+ "$ref": "#/components/schemas/Track"
+ },
+ "maxItems": 1000,
+ "minItems": 1,
+ "type": "array"
}
},
"required": [
- "track"
+ "tracks"
],
"type": "object"
},
@@ -1655,7 +1714,7 @@
},
"info": {
"title": "Alisten",
- "version": "189d6eb97c028124"
+ "version": "000976bd80c1113a"
},
"openapi": "3.1.0",
"paths": {
@@ -2946,6 +3005,59 @@
"summary": "重命名歌单;“我喜欢”不能重命名"
}
},
+ "/api/me/playlists/{id}/copies": {
+ "post": {
+ "operationId": "copyMyPlaylistItems",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CopyMyPlaylistItemsRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CopyMyPlaylistItemsResponse"
+ }
+ }
+ },
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "memberToken": []
+ }
+ ],
+ "summary": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首;不在来源歌单里的和已在这个歌单里的跳过;新复制的排在最前,保持 tracks 的先后;装不下时整批拒绝;返回实际复制的数量"
+ }
+ },
"/api/me/playlists/{id}/items": {
"get": {
"operationId": "listMyPlaylistItems",
@@ -3040,9 +3152,9 @@
"summary": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首"
}
},
- "/api/me/playlists/{id}/removals": {
+ "/api/me/playlists/{id}/moves": {
"post": {
- "operationId": "removeMyPlaylistItem",
+ "operationId": "moveMyPlaylistItems",
"parameters": [
{
"in": "path",
@@ -3057,7 +3169,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/RemoveMyPlaylistItemRequest"
+ "$ref": "#/components/schemas/MoveMyPlaylistItemsRequest"
}
}
},
@@ -3083,7 +3195,53 @@
"memberToken": []
}
],
- "summary": "把一首歌移出歌单;不在歌单里也算成功"
+ "summary": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后;一次 1 到 1000 首;before 不能在 tracks 里,也必须还在歌单里"
+ }
+ },
+ "/api/me/playlists/{id}/removals": {
+ "post": {
+ "operationId": "removeMyPlaylistItems",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RemoveMyPlaylistItemsRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "成功"
+ },
+ "default": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ },
+ "description": "失败"
+ }
+ },
+ "security": [
+ {
+ "memberToken": []
+ }
+ ],
+ "summary": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略"
}
},
"/api/members": {
diff --git a/protocol/types.gen.go b/protocol/types.gen.go
index 77ed9d0..1857798 100644
--- a/protocol/types.gen.go
+++ b/protocol/types.gen.go
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
-const Version = "189d6eb97c028124"
+const Version = "000976bd80c1113a"
type APIKey struct {
Key string `json:"key"`
@@ -164,6 +164,23 @@ type ChatSendRequest struct {
Content string `json:"content"`
}
+type CopyMyPlaylistItemsRequest struct {
+ From string `json:"from"`
+ Tracks []Track `json:"tracks"`
+}
+
+func (v CopyMyPlaylistItemsRequest) MarshalJSON() ([]byte, error) {
+ type plain CopyMyPlaylistItemsRequest
+ if v.Tracks == nil {
+ v.Tracks = []Track{}
+ }
+ return json.Marshal(plain(v))
+}
+
+type CopyMyPlaylistItemsResponse struct {
+ Added int64 `json:"added"`
+}
+
type CreateMemberRequest struct{}
type CreateMemberResponse struct {
@@ -323,6 +340,19 @@ func (v Members) MarshalJSON() ([]byte, error) {
return json.Marshal(plain(v))
}
+type MoveMyPlaylistItemsRequest struct {
+ Before *Track `json:"before,omitempty"`
+ Tracks []Track `json:"tracks"`
+}
+
+func (v MoveMyPlaylistItemsRequest) MarshalJSON() ([]byte, error) {
+ type plain MoveMyPlaylistItemsRequest
+ if v.Tracks == nil {
+ v.Tracks = []Track{}
+ }
+ return json.Marshal(plain(v))
+}
+
type MyPlaylist struct {
CoverURL *string `json:"coverUrl"`
CreatedAtMs int64 `json:"createdAtMs"`
@@ -494,8 +524,16 @@ type RegisterResponse struct {
Account Account `json:"account"`
}
-type RemoveMyPlaylistItemRequest struct {
- Track Track `json:"track"`
+type RemoveMyPlaylistItemsRequest struct {
+ Tracks []Track `json:"tracks"`
+}
+
+func (v RemoveMyPlaylistItemsRequest) MarshalJSON() ([]byte, error) {
+ type plain RemoveMyPlaylistItemsRequest
+ if v.Tracks == nil {
+ v.Tracks = []Track{}
+ }
+ return json.Marshal(plain(v))
}
type RenameMyPlaylistRequest struct {
@@ -724,7 +762,9 @@ var (
RouteDeleteMyPlaylist = Route{Pattern: "DELETE /api/me/playlists/{id}", Auth: AuthMember, Status: 204}
RouteListMyPlaylistItems = Route{Pattern: "GET /api/me/playlists/{id}/items", Auth: AuthMember, Status: 200}
RouteAddMyPlaylistItem = Route{Pattern: "POST /api/me/playlists/{id}/items", Auth: AuthMember, Status: 201}
- RouteRemoveMyPlaylistItem = Route{Pattern: "POST /api/me/playlists/{id}/removals", Auth: AuthMember, Status: 204}
+ RouteRemoveMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/removals", Auth: AuthMember, Status: 204}
+ RouteCopyMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/copies", Auth: AuthMember, Status: 200}
+ RouteMoveMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/moves", Auth: AuthMember, Status: 204}
RouteListRooms = Route{Pattern: "GET /api/rooms", Auth: AuthNone, Status: 200}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
diff --git a/web/nginx/templates/alisten.d/proxy.conf.template b/web/nginx/templates/alisten.d/proxy.conf.template
index d3718de..978234c 100644
--- a/web/nginx/templates/alisten.d/proxy.conf.template
+++ b/web/nginx/templates/alisten.d/proxy.conf.template
@@ -11,6 +11,14 @@ location ^~ /api/ {
client_body_buffer_size 64k;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://$serve;
+
+ # 取值必须不小于 serve 对批量歌单接口的请求体上限 maxBatchBodyBytes(2304000 字节),否则大请求会被 nginx 以 HTML 的 413 拒绝,或写入临时文件。
+ location ~ ^/api/me/playlists/[^/]+/(removals|copies|moves)$ {
+ client_max_body_size 2250k;
+ client_body_buffer_size 2250k;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_pass http://$serve;
+ }
}
location ^~ /media/ {
proxy_pass http://$serve;
diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts
index 4803b48..3f96de9 100644
--- a/web/src/core/api/http.gen.ts
+++ b/web/src/core/api/http.gen.ts
@@ -109,6 +109,15 @@ export interface ChangePasswordRequest {
newPassword: string
}
+export interface CopyMyPlaylistItemsRequest {
+ from: string
+ tracks: Track[]
+}
+
+export interface CopyMyPlaylistItemsResponse {
+ added: number
+}
+
export type CreateMemberRequest = Record
export interface CreateMemberResponse {
@@ -171,6 +180,11 @@ export interface LoginResponse {
memberToken: string
}
+export interface MoveMyPlaylistItemsRequest {
+ before?: Track
+ tracks: Track[]
+}
+
export interface RegisterRequest {
password: string
username: string
@@ -180,8 +194,8 @@ export interface RegisterResponse {
account: Account
}
-export interface RemoveMyPlaylistItemRequest {
- track: Track
+export interface RemoveMyPlaylistItemsRequest {
+ tracks: Track[]
}
export interface RenameMyPlaylistRequest {
@@ -227,7 +241,9 @@ export function httpClient(send: HttpSend) {
deleteMyPlaylist: (id: string) => send({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}`, auth: 'member' }),
listMyPlaylistItems: (id: string) => send({ method: 'GET', path: `/api/me/playlists/${encodeURIComponent(id)}/items`, auth: 'member' }),
addMyPlaylistItem: (id: string, body: AddMyPlaylistItemRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/items`, auth: 'member', body }),
- removeMyPlaylistItem: (id: string, body: RemoveMyPlaylistItemRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }),
+ removeMyPlaylistItems: (id: string, body: RemoveMyPlaylistItemsRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }),
+ copyMyPlaylistItems: (id: string, body: CopyMyPlaylistItemsRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/copies`, auth: 'member', body }),
+ moveMyPlaylistItems: (id: string, body: MoveMyPlaylistItemsRequest) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/moves`, auth: 'member', body }),
listRooms: () => send({ method: 'GET', path: '/api/rooms', auth: 'none' }),
getRoom: (ref: string) => send({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts
index be3d1ee..acb94e8 100644
--- a/web/src/core/realtime/protocol.gen.ts
+++ b/web/src/core/realtime/protocol.gen.ts
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
-export const PROTOCOL_VERSION = '189d6eb97c028124'
+export const PROTOCOL_VERSION = '000976bd80c1113a'
export interface APIKey {
key: string