feat: remove, copy and reorder several playlist songs at once

This commit is contained in:
DiheChen committed 2026-11-09 21:28:12 +08:00
1 parent fafde9041c
commit 2657dca9e1
34 files changed
+1578 -92

No files matched your search

+64 -7
View File
@@ -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
+29
View File
@@ -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))
+27
View File
@@ -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)
}
}
}
+45 -4
View File
@@ -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"]
}
},
{
+167 -9
View File
@@ -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": {
+44 -4
View File
@@ -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}