feat: remove, copy and reorder several playlist songs at once
This commit is contained in:
1 parent
fafde9041c
commit
2657dca9e1
34 files changed
+1578
-92
No files matched your search
@@ -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
@@ -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")
|
||||
|
||||
@@ -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{}))}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ""))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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("移动之后更新时间没有推进")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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": "歌单不存在"}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in new issue
Block a user