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

+5
View File
@@ -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))
}
+36 -3
View File
@@ -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")
+22 -6
View File
@@ -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{}))}
+22
View File
@@ -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)
+15
View File
@@ -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",
+49 -1
View File
@@ -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": "座位上的成员,<b>座位号</b> 从 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": []
}
}
}
}
+14 -1
View File
@@ -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}
@@ -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 = <T>(request: HttpRequest) => Promise<T>
export function httpClient(send: HttpSend) {
return {
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
removeTracks: (body: RemoveTracksRequest) => send<void>({ method: 'POST', path: '/api/tracks/removals', auth: 'none', body }),
getRoom: (ref: string) => send<Room>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
getSeat: (ref: string, seat: string) => send<Member>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
@@ -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<string, never>
+39 -3
View File
@@ -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;
+18 -7
View File
@@ -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) {
+49
View File
@@ -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, ""))
+1 -1
View File
@@ -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)
+37 -3
View File
@@ -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)
}
@@ -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
}
+91 -3
View File
@@ -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
}
+275 -13
View File
@@ -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("移动之后更新时间没有推进")
}
}
+20 -2
View File
@@ -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 {
+57 -1
View File
@@ -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)
}
}
+8 -2
View File
@@ -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
}
+57 -2
View File
@@ -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
}
+1 -1
View File
@@ -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)
+130 -5
View File
@@ -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": "歌单不存在"}}
+44
View File
@@ -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
}
+60
View File
@@ -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)
}
})
}
}
+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}
@@ -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;
+19 -3
View File
@@ -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<string, never>
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<void>({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}`, auth: 'member' }),
listMyPlaylistItems: (id: string) => send<ListMyPlaylistItemsResponse>({ method: 'GET', path: `/api/me/playlists/${encodeURIComponent(id)}/items`, auth: 'member' }),
addMyPlaylistItem: (id: string, body: AddMyPlaylistItemRequest) => send<AddMyPlaylistItemResponse>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/items`, auth: 'member', body }),
removeMyPlaylistItem: (id: string, body: RemoveMyPlaylistItemRequest) => send<void>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }),
removeMyPlaylistItems: (id: string, body: RemoveMyPlaylistItemsRequest) => send<void>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/removals`, auth: 'member', body }),
copyMyPlaylistItems: (id: string, body: CopyMyPlaylistItemsRequest) => send<CopyMyPlaylistItemsResponse>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/copies`, auth: 'member', body }),
moveMyPlaylistItems: (id: string, body: MoveMyPlaylistItemsRequest) => send<void>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/moves`, auth: 'member', body }),
listRooms: () => send<ListRoomsResponse>({ method: 'GET', path: '/api/rooms', auth: 'none' }),
getRoom: (ref: string) => send<RoomListing>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
+1 -1
View File
@@ -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