From 2657dca9e1201d5604f3eb55f2653813bdeb406d Mon Sep 17 00:00:00 2001 From: DiheChen Date: Mon, 9 Nov 2026 21:28:12 +0800 Subject: [PATCH] feat: remove, copy and reorder several playlist songs at once --- cmd/protocolgen/godecode.go | 5 + cmd/protocolgen/idl.go | 39 ++- cmd/protocolgen/idl_test.go | 28 +- .../testdata/sample/protocol/decode.gen.go | 22 ++ .../testdata/sample/protocol/idl.json | 15 + .../testdata/sample/protocol/openapi.gen.json | 50 ++- .../testdata/sample/protocol/types.gen.go | 15 +- .../sample/web/src/core/api/http.gen.ts | 7 +- .../web/src/core/realtime/protocol.gen.ts | 2 +- db/queries/playlists.sql | 42 ++- internal/adapter/in/api/api.go | 25 +- internal/adapter/in/api/api_test.go | 49 +++ internal/adapter/in/api/events.go | 2 +- internal/adapter/in/api/playlist.go | 40 ++- .../postgres/internal/query/playlists.sql.go | 125 +++++++- internal/adapter/out/postgres/playlists.go | 94 +++++- .../adapter/out/postgres/playlists_test.go | 288 +++++++++++++++++- internal/app/playlist/playlist.go | 22 +- internal/app/playlist/playlist_test.go | 58 +++- internal/app/port/playlist.go | 10 +- internal/app/port/porttest/store.go | 59 +++- internal/bootstrap/media_test.go | 2 +- .../bootstrap/testdata/http/playlists.txtar | 135 +++++++- internal/domain/playlist/order.go | 44 +++ internal/domain/playlist/order_test.go | 60 ++++ protocol/decode.gen.go | 71 ++++- protocol/decode.go | 29 ++ protocol/decode_test.go | 27 ++ protocol/idl.json | 49 ++- protocol/openapi.gen.json | 176 ++++++++++- protocol/types.gen.go | 48 ++- .../templates/alisten.d/proxy.conf.template | 8 + web/src/core/api/http.gen.ts | 22 +- web/src/core/realtime/protocol.gen.ts | 2 +- 34 files changed, 1578 insertions(+), 92 deletions(-) create mode 100644 internal/domain/playlist/order.go create mode 100644 internal/domain/playlist/order_test.go 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 654a0aa..4118350 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 bc9339f..246f2c5 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 aeed03e..833ffc5 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" ) @@ -674,7 +675,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) @@ -682,9 +683,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 1f85c8f..0d2526c 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -99,8 +99,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) { @@ -451,6 +459,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) @@ -583,6 +612,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") @@ -761,16 +815,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 c1fbf1f..a4ebcb0 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -930,8 +930,8 @@ } }, { - "name": "removeMyPlaylistItem", - "description": "把一首歌移出歌单;不在歌单里也算成功", + "name": "removeMyPlaylistItems", + "description": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略", "method": "POST", "path": "/api/me/playlists/{id}/removals", "auth": "member", @@ -940,9 +940,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 f6c5ac0..cd82197 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" @@ -817,6 +851,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": { @@ -1189,15 +1243,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" }, @@ -1666,7 +1725,7 @@ }, "info": { "title": "Alisten", - "version": "7380d77bc52d7f95" + "version": "1b53e1e08b0452e4" }, "openapi": "3.1.0", "paths": { @@ -2957,6 +3016,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", @@ -3051,9 +3163,9 @@ "summary": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首" } }, - "/api/me/playlists/{id}/removals": { + "/api/me/playlists/{id}/moves": { "post": { - "operationId": "removeMyPlaylistItem", + "operationId": "moveMyPlaylistItems", "parameters": [ { "in": "path", @@ -3068,7 +3180,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RemoveMyPlaylistItemRequest" + "$ref": "#/components/schemas/MoveMyPlaylistItemsRequest" } } }, @@ -3094,7 +3206,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 4464cb4..667a985 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "7380d77bc52d7f95" +const Version = "1b53e1e08b0452e4" 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 { @@ -325,6 +342,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"` @@ -496,8 +526,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 { @@ -726,7 +764,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 52552e3..a4a4b49 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 { @@ -173,6 +182,11 @@ export interface LoginResponse { memberToken: string } +export interface MoveMyPlaylistItemsRequest { + before?: Track + tracks: Track[] +} + export interface RegisterRequest { password: string username: string @@ -182,8 +196,8 @@ export interface RegisterResponse { account: Account } -export interface RemoveMyPlaylistItemRequest { - track: Track +export interface RemoveMyPlaylistItemsRequest { + tracks: Track[] } export interface RenameMyPlaylistRequest { @@ -229,7 +243,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 8dd11c5..f0ab4aa 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 = '7380d77bc52d7f95' +export const PROTOCOL_VERSION = '1b53e1e08b0452e4' export interface APIKey { key: string