feat: remove, copy and reorder several playlist songs at once
This commit is contained in:
1 parent
d9a97b6331
commit
5c34450ccf
30 files changed
+1482
-74
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)", refName(p.Items.Ref))
|
||||
case p.Ref != "":
|
||||
decode = "decode" + refName(p.Ref)
|
||||
case p.Type == "integer":
|
||||
@@ -149,6 +151,12 @@ 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.MaxItems != nil {
|
||||
check("checkMaxItems", fmt.Sprint(*s.MaxItems))
|
||||
}
|
||||
if s.Minimum != nil {
|
||||
check("checkMinimum", fmt.Sprint(*s.Minimum))
|
||||
}
|
||||
|
||||
+33
-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,44 @@ 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.MinItems != nil && *s.MinItems < 0 || s.MaxItems != nil && *s.MaxItems < 0 {
|
||||
return fmt.Errorf("%s:minItems 和 maxItems 不能是负数", where)
|
||||
}
|
||||
if s.MinItems != nil && s.MaxItems != 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 +987,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 +1083,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,28 @@ 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:数组必须是必填字段"},
|
||||
{"请求数组的 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,27 @@ 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)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 3); 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;
|
||||
@@ -27,6 +27,7 @@ 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"
|
||||
@@ -117,7 +118,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.handle(mux, protocol.RouteRemoveMyPlaylistItems, srv.removeMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteCopyMyPlaylistItems, srv.copyMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteMoveMyPlaylistItems, srv.moveMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
|
||||
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
|
||||
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
|
||||
@@ -320,6 +323,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},
|
||||
|
||||
@@ -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,13 +198,99 @@ 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 {
|
||||
err := s.owned(ctx, ownerID, id)
|
||||
if err == nil {
|
||||
err = s.q.DeletePlaylistItem(ctx, query.DeletePlaylistItemParams{PlaylistID: id, Source: string(t.Source), TrackID: t.ID})
|
||||
sources, ids := splitTracks(tracks)
|
||||
err = s.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)
|
||||
}
|
||||
}
|
||||
@@ -228,8 +229,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 {
|
||||
@@ -273,3 +278,237 @@ func TestPlaylistCoverIsTheFirstSongWithOne(t *testing.T) {
|
||||
t.Errorf("封面 = %+v,期望跳过没有封面的歌,取排在最前的 %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
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 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"
|
||||
)
|
||||
@@ -643,7 +644,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)
|
||||
@@ -651,9 +652,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
|
||||
}
|
||||
+129
-4
@@ -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 --
|
||||
GET /api/me/playlists/$favorites/items
|
||||
Authorization: Bearer $bob
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+73
-7
@@ -98,8 +98,16 @@ func DecodeAddMyPlaylistItemRequest(body []byte) (AddMyPlaylistItemRequest, erro
|
||||
return decodeAddMyPlaylistItemRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeRemoveMyPlaylistItemRequest(body []byte) (RemoveMyPlaylistItemRequest, error) {
|
||||
return decodeRemoveMyPlaylistItemRequest(body, "")
|
||||
func DecodeRemoveMyPlaylistItemsRequest(body []byte) (RemoveMyPlaylistItemsRequest, error) {
|
||||
return decodeRemoveMyPlaylistItemsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCopyMyPlaylistItemsRequest(body []byte) (CopyMyPlaylistItemsRequest, error) {
|
||||
return decodeCopyMyPlaylistItemsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeMoveMyPlaylistItemsRequest(body []byte) (MoveMyPlaylistItemsRequest, error) {
|
||||
return decodeMoveMyPlaylistItemsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
|
||||
@@ -450,6 +458,30 @@ 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)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeCreateMemberRequest(raw json.RawMessage, path string) (CreateMemberRequest, error) {
|
||||
var v CreateMemberRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
@@ -572,6 +604,34 @@ 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)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodePingRequest(raw json.RawMessage, path string) (PingRequest, error) {
|
||||
var v PingRequest
|
||||
m, err := decodeObject(raw, path, "clientTimeMs")
|
||||
@@ -750,16 +810,22 @@ 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)(m["tracks"], field(path, "tracks")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
|
||||
@@ -88,6 +88,38 @@ func decodeInteger(raw json.RawMessage, path string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func decodeArray[T any](decode func(json.RawMessage, string) (T, error)) 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, "应为数组")
|
||||
}
|
||||
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 checkMaxItems[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,27 @@ 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)
|
||||
}
|
||||
}
|
||||
tooMany := `{"tracks":[` + one + strings.Repeat(","+one, 1000) + `]}`
|
||||
if got := errText(func() error { _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tooMany)); return err }()); got != "tracks:元素不能超过 1000 个" {
|
||||
t.Errorf("1001 首 = %q", got)
|
||||
}
|
||||
}
|
||||
+45
-4
@@ -923,8 +923,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "removeMyPlaylistItem",
|
||||
"description": "把一首歌移出歌单;不在歌单里也算成功",
|
||||
"name": "removeMyPlaylistItems",
|
||||
"description": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/removals",
|
||||
"auth": "member",
|
||||
@@ -933,9 +933,50 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {"$ref": "#/$defs/Track"}
|
||||
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000}
|
||||
},
|
||||
"required": ["track"]
|
||||
"required": ["tracks"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "copyMyPlaylistItems",
|
||||
"description": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首;不在来源歌单里的和已在这个歌单里的跳过;新复制的排在最前,保持 tracks 的先后;装不下时整批拒绝;返回实际复制的数量",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/copies",
|
||||
"auth": "member",
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"from": {"type": "string", "description": "来源歌单的 ID"},
|
||||
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000}
|
||||
},
|
||||
"required": ["from", "tracks"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"added": {"type": "integer"}
|
||||
},
|
||||
"required": ["added"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "moveMyPlaylistItems",
|
||||
"description": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后;一次 1 到 1000 首;before 不能在 tracks 里,也必须还在歌单里",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/moves",
|
||||
"auth": "member",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000},
|
||||
"before": {"$ref": "#/$defs/Track"}
|
||||
},
|
||||
"required": ["tracks"]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+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"
|
||||
@@ -806,6 +840,26 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MoveMyPlaylistItemsRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"before": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
},
|
||||
"tracks": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
},
|
||||
"maxItems": 1000,
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tracks"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MyPlaylist": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -1178,15 +1232,20 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RemoveMyPlaylistItemRequest": {
|
||||
"RemoveMyPlaylistItemsRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
"tracks": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
},
|
||||
"maxItems": 1000,
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"track"
|
||||
"tracks"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -1655,7 +1714,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Alisten",
|
||||
"version": "1132502d511e1bab"
|
||||
"version": "236deb132b07fa9e"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -2946,6 +3005,59 @@
|
||||
"summary": "重命名歌单;“我喜欢”不能重命名"
|
||||
}
|
||||
},
|
||||
"/api/me/playlists/{id}/copies": {
|
||||
"post": {
|
||||
"operationId": "copyMyPlaylistItems",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CopyMyPlaylistItemsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CopyMyPlaylistItemsResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "把另一个歌单里的若干首歌连同元数据复制到这个歌单,一次 1 到 1000 首;不在来源歌单里的和已在这个歌单里的跳过;新复制的排在最前,保持 tracks 的先后;装不下时整批拒绝;返回实际复制的数量"
|
||||
}
|
||||
},
|
||||
"/api/me/playlists/{id}/items": {
|
||||
"get": {
|
||||
"operationId": "listMyPlaylistItems",
|
||||
@@ -3040,9 +3152,9 @@
|
||||
"summary": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首"
|
||||
}
|
||||
},
|
||||
"/api/me/playlists/{id}/removals": {
|
||||
"/api/me/playlists/{id}/moves": {
|
||||
"post": {
|
||||
"operationId": "removeMyPlaylistItem",
|
||||
"operationId": "moveMyPlaylistItems",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
@@ -3057,7 +3169,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RemoveMyPlaylistItemRequest"
|
||||
"$ref": "#/components/schemas/MoveMyPlaylistItemsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3083,7 +3195,53 @@
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "把一首歌移出歌单;不在歌单里也算成功"
|
||||
"summary": "调整歌单里的顺序:把 tracks 里在歌单中的歌按给出的先后放到 before 之前,不给 before 就放到最后;一次 1 到 1000 首;before 不能在 tracks 里,也必须还在歌单里"
|
||||
}
|
||||
},
|
||||
"/api/me/playlists/{id}/removals": {
|
||||
"post": {
|
||||
"operationId": "removeMyPlaylistItems",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RemoveMyPlaylistItemsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略"
|
||||
}
|
||||
},
|
||||
"/api/members": {
|
||||
|
||||
+44
-4
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "1132502d511e1bab"
|
||||
const Version = "236deb132b07fa9e"
|
||||
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
@@ -164,6 +164,23 @@ type ChatSendRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type CopyMyPlaylistItemsRequest struct {
|
||||
From string `json:"from"`
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
|
||||
func (v CopyMyPlaylistItemsRequest) MarshalJSON() ([]byte, error) {
|
||||
type plain CopyMyPlaylistItemsRequest
|
||||
if v.Tracks == nil {
|
||||
v.Tracks = []Track{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type CopyMyPlaylistItemsResponse struct {
|
||||
Added int64 `json:"added"`
|
||||
}
|
||||
|
||||
type CreateMemberRequest struct{}
|
||||
|
||||
type CreateMemberResponse struct {
|
||||
@@ -323,6 +340,19 @@ func (v Members) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type MoveMyPlaylistItemsRequest struct {
|
||||
Before *Track `json:"before,omitempty"`
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
|
||||
func (v MoveMyPlaylistItemsRequest) MarshalJSON() ([]byte, error) {
|
||||
type plain MoveMyPlaylistItemsRequest
|
||||
if v.Tracks == nil {
|
||||
v.Tracks = []Track{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type MyPlaylist struct {
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
@@ -494,8 +524,16 @@ type RegisterResponse struct {
|
||||
Account Account `json:"account"`
|
||||
}
|
||||
|
||||
type RemoveMyPlaylistItemRequest struct {
|
||||
Track Track `json:"track"`
|
||||
type RemoveMyPlaylistItemsRequest struct {
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
|
||||
func (v RemoveMyPlaylistItemsRequest) MarshalJSON() ([]byte, error) {
|
||||
type plain RemoveMyPlaylistItemsRequest
|
||||
if v.Tracks == nil {
|
||||
v.Tracks = []Track{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type RenameMyPlaylistRequest struct {
|
||||
@@ -724,7 +762,9 @@ var (
|
||||
RouteDeleteMyPlaylist = Route{Pattern: "DELETE /api/me/playlists/{id}", Auth: AuthMember, Status: 204}
|
||||
RouteListMyPlaylistItems = Route{Pattern: "GET /api/me/playlists/{id}/items", Auth: AuthMember, Status: 200}
|
||||
RouteAddMyPlaylistItem = Route{Pattern: "POST /api/me/playlists/{id}/items", Auth: AuthMember, Status: 201}
|
||||
RouteRemoveMyPlaylistItem = Route{Pattern: "POST /api/me/playlists/{id}/removals", Auth: AuthMember, Status: 204}
|
||||
RouteRemoveMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/removals", Auth: AuthMember, Status: 204}
|
||||
RouteCopyMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/copies", Auth: AuthMember, Status: 200}
|
||||
RouteMoveMyPlaylistItems = Route{Pattern: "POST /api/me/playlists/{id}/moves", Auth: AuthMember, Status: 204}
|
||||
RouteListRooms = Route{Pattern: "GET /api/rooms", Auth: AuthNone, Status: 200}
|
||||
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
|
||||
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
|
||||
|
||||
@@ -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 {
|
||||
@@ -171,6 +180,11 @@ export interface LoginResponse {
|
||||
memberToken: string
|
||||
}
|
||||
|
||||
export interface MoveMyPlaylistItemsRequest {
|
||||
before?: Track
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
password: string
|
||||
username: string
|
||||
@@ -180,8 +194,8 @@ export interface RegisterResponse {
|
||||
account: Account
|
||||
}
|
||||
|
||||
export interface RemoveMyPlaylistItemRequest {
|
||||
track: Track
|
||||
export interface RemoveMyPlaylistItemsRequest {
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface RenameMyPlaylistRequest {
|
||||
@@ -227,7 +241,9 @@ export function httpClient(send: HttpSend) {
|
||||
deleteMyPlaylist: (id: string) => send<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 = '1132502d511e1bab'
|
||||
export const PROTOCOL_VERSION = '236deb132b07fa9e'
|
||||
|
||||
export interface APIKey {
|
||||
key: string
|
||||
|
||||
Reference in new issue
Block a user