Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99987a63bf | ||
|
|
5ee219caba | ||
|
|
4f6858b17c | ||
|
|
4e2c24bdca | ||
|
|
9338c65877 | ||
|
|
b8e64a88af | ||
|
|
27a98e990a | ||
|
|
d5e1de09db | ||
|
|
e103b75d4b | ||
|
|
50a51a9b05 | ||
|
|
ebf7262cad | ||
|
|
4c75a5c609 | ||
|
|
d8a79e8bc8 | ||
|
|
4f056e91d4 | ||
|
|
0bc5568335 | ||
|
|
4ed246dc49 | ||
|
|
fb481fd5d8 |
No files matched your search
@@ -102,8 +102,6 @@ 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":
|
||||
@@ -151,12 +149,6 @@ 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))
|
||||
}
|
||||
|
||||
+3
-33
@@ -107,8 +107,6 @@ 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"`
|
||||
@@ -441,44 +439,21 @@ 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 !required {
|
||||
return fmt.Errorf("%s:数组必须是必填字段", where)
|
||||
if inbound || !required {
|
||||
return fmt.Errorf("%s:数组只能出现在响应和事件中,并且必须是必填字段", where)
|
||||
}
|
||||
allowed := []string{"type", "description", "items"}
|
||||
if inbound {
|
||||
allowed = append(allowed, "minItems", "maxItems")
|
||||
}
|
||||
if err := allowOnly(where, s, allowed...); err != nil {
|
||||
if err := allowOnly(where, s, "type", "description", "items"); 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 {
|
||||
@@ -987,9 +962,6 @@ 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
|
||||
}
|
||||
@@ -1083,8 +1055,6 @@ 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,28 +143,17 @@ 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": ref("Empty")},
|
||||
})
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
|
||||
}, "$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": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
}, "ids")
|
||||
d["x-events"] = []any{map[string]any{"type": "batch", "data": ref("Batch")}}
|
||||
}, "不支持 minItems"},
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
|
||||
}, "$defs.Batch.properties.ids:数组只能出现在响应和事件中"},
|
||||
{"生成的类型名重名", func(d map[string]any) {
|
||||
defs(d)["RoomSyncRequest"] = object(map[string]any{})
|
||||
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
|
||||
|
||||
@@ -48,10 +48,6 @@ 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, "")
|
||||
}
|
||||
@@ -261,27 +257,6 @@ 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,21 +212,6 @@
|
||||
"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",
|
||||
|
||||
+1
-49
@@ -183,23 +183,6 @@
|
||||
},
|
||||
"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": "房间",
|
||||
@@ -335,7 +318,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Sample",
|
||||
"version": "5e495c1e23fa03a5"
|
||||
"version": "362d144af81ba8bf"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -791,37 +774,6 @@
|
||||
],
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-14
@@ -4,7 +4,7 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "5e495c1e23fa03a5"
|
||||
const Version = "362d144af81ba8bf"
|
||||
|
||||
type AdminUpdateRoomRequest struct {
|
||||
Homepage *string `json:"homepage,omitempty"`
|
||||
@@ -88,18 +88,6 @@ 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"`
|
||||
@@ -166,7 +154,6 @@ 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, Track } from '../realtime/protocol.gen'
|
||||
import type { ErrorInfo, Member, Room } from '../realtime/protocol.gen'
|
||||
|
||||
export interface AdminUpdateRoomRequest {
|
||||
homepage?: string
|
||||
@@ -25,10 +25,6 @@ export interface CreateRoomResponse {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface RemoveTracksRequest {
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: ErrorInfo
|
||||
}
|
||||
@@ -47,7 +43,6 @@ 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 = '5e495c1e23fa03a5'
|
||||
export const PROTOCOL_VERSION = '362d144af81ba8bf'
|
||||
|
||||
export type Empty = Record<string, never>
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
-- +goose Up
|
||||
-- 一个会话对应一个成员 token,同一个成员可以在多台设备上各有一个会话。
|
||||
CREATE TABLE member_sessions (
|
||||
token_hash bytea PRIMARY KEY,
|
||||
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON member_sessions (member_id);
|
||||
|
||||
-- bot 成员只通过 API Key 使用 HTTP 接口,不需要会话。
|
||||
INSERT INTO member_sessions (token_hash, member_id, created_at)
|
||||
SELECT m.token_hash, m.id, m.created_at FROM members m
|
||||
WHERE NOT EXISTS (SELECT FROM rooms r WHERE r.bot_member_id = m.id);
|
||||
|
||||
ALTER TABLE members DROP COLUMN token_hash;
|
||||
|
||||
-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。
|
||||
-- 邮箱属于账号,在所有设备上一致;游客没有账号,不替匿名身份保存个人信息,邮箱只存在游客自己的浏览器里。
|
||||
ALTER TABLE members
|
||||
ADD COLUMN username text UNIQUE,
|
||||
ADD COLUMN password_hash text,
|
||||
ADD CHECK (password_hash IS NULL OR username IS NOT NULL),
|
||||
ADD COLUMN email text,
|
||||
ADD CHECK (email IS NULL OR username IS NOT NULL);
|
||||
|
||||
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten;没有行时开放注册。
|
||||
ALTER TABLE site
|
||||
ALTER COLUMN brand_name DROP NOT NULL,
|
||||
ADD COLUMN registration_open boolean NOT NULL DEFAULT true;
|
||||
|
||||
-- 票据属于签发它的会话,会话删除时票据随之失效。bot 的 API Key 票据没有会话。
|
||||
ALTER TABLE room_tickets ADD COLUMN session_token_hash bytea REFERENCES member_sessions ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX ON room_tickets (session_token_hash);
|
||||
|
||||
UPDATE room_tickets t SET session_token_hash = s.token_hash
|
||||
FROM member_sessions s
|
||||
WHERE s.member_id = t.member_id;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- +goose Up
|
||||
-- 一个会话对应一个成员 token,同一个成员可以在多台设备上各有一个会话。
|
||||
CREATE TABLE member_sessions (
|
||||
token_hash bytea PRIMARY KEY,
|
||||
member_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON member_sessions (member_id);
|
||||
|
||||
-- bot 成员只通过 API Key 使用 HTTP 接口,不需要会话。
|
||||
INSERT INTO member_sessions (token_hash, member_id, created_at)
|
||||
SELECT m.token_hash, m.id, m.created_at FROM members m
|
||||
WHERE NOT EXISTS (SELECT FROM rooms r WHERE r.bot_member_id = m.id);
|
||||
|
||||
ALTER TABLE members DROP COLUMN token_hash;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
-- 设置了用户名的成员是已注册的账号,否则是游客。用户名只能由小写字母、数字、下划线和连字符组成,所以按原文唯一即可。
|
||||
ALTER TABLE members
|
||||
ADD COLUMN username text UNIQUE,
|
||||
ADD COLUMN password_hash text,
|
||||
ADD CHECK (password_hash IS NULL OR username IS NOT NULL);
|
||||
@@ -1,35 +0,0 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE playlists (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
owner_id uuid NOT NULL REFERENCES members ON DELETE CASCADE,
|
||||
-- 每个账号恰好一个“我喜欢”:注册时创建,不能改名或删除。
|
||||
kind text NOT NULL CHECK (kind IN ('favorites', 'custom')),
|
||||
name text NOT NULL,
|
||||
-- 新加入的曲目取 item_seq + 1 作为排序值,排在最前。
|
||||
item_seq bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- 歌单改名或曲目增减的时间。
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON playlists (owner_id);
|
||||
CREATE UNIQUE INDEX ON playlists (owner_id) WHERE kind = 'favorites';
|
||||
|
||||
-- 展示用的元数据存一份快照:歌单里的歌不一定准备过,tracks 表里可能没有。
|
||||
CREATE TABLE playlist_items (
|
||||
playlist_id uuid NOT NULL REFERENCES playlists ON DELETE CASCADE,
|
||||
source text NOT NULL,
|
||||
track_id text NOT NULL,
|
||||
title text NOT NULL,
|
||||
artist text,
|
||||
album text,
|
||||
cover_url text,
|
||||
cover_key text,
|
||||
duration_ms bigint NOT NULL,
|
||||
page_url text NOT NULL,
|
||||
position bigint NOT NULL,
|
||||
added_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (playlist_id, source, track_id)
|
||||
);
|
||||
|
||||
CREATE INDEX ON playlist_items (playlist_id, position DESC);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
-- site 表没有行或者 brand_name 为 NULL 时,品牌名是默认的 Alisten;没有行时开放注册。
|
||||
ALTER TABLE site
|
||||
ALTER COLUMN brand_name DROP NOT NULL,
|
||||
ADD COLUMN registration_open boolean NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- +goose Up
|
||||
-- 票据属于签发它的会话,会话删除时票据随之失效。bot 的 API Key 票据没有会话。
|
||||
ALTER TABLE room_tickets ADD COLUMN session_token_hash bytea REFERENCES member_sessions ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX ON room_tickets (session_token_hash);
|
||||
|
||||
-- 只有一个会话的成员,已有的票据只可能来自这个会话。对应不到唯一会话的成员票据直接删除,客户端会重新换取。
|
||||
UPDATE room_tickets t SET session_token_hash = s.token_hash
|
||||
FROM member_sessions s
|
||||
WHERE s.member_id = t.member_id
|
||||
AND (SELECT count(*) FROM member_sessions o WHERE o.member_id = t.member_id) = 1;
|
||||
|
||||
DELETE FROM room_tickets t
|
||||
WHERE t.session_token_hash IS NULL
|
||||
AND NOT EXISTS (SELECT FROM rooms r WHERE r.id = t.room_id AND r.bot_member_id = t.member_id);
|
||||
@@ -9,16 +9,15 @@ INSERT INTO member_sessions (token_hash, member_id) VALUES (@token_hash, @member
|
||||
SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = @token_hash);
|
||||
|
||||
-- name: UpdateMember :one
|
||||
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
|
||||
-- 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
|
||||
UPDATE members
|
||||
SET name = coalesce(sqlc.narg(name), name),
|
||||
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END,
|
||||
email = CASE WHEN username IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
|
||||
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END
|
||||
WHERE id = @id
|
||||
RETURNING *;
|
||||
|
||||
-- name: MemberAccountProfile :one
|
||||
SELECT username, email FROM members WHERE id = @id;
|
||||
-- name: MemberUsername :one
|
||||
SELECT username FROM members WHERE id = @id;
|
||||
|
||||
-- name: RegisterMember :execrows
|
||||
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
-- name: CreateFavorites :exec
|
||||
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'favorites', '我喜欢');
|
||||
|
||||
-- name: MemberPlaylists :many
|
||||
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
|
||||
FROM playlists p
|
||||
LEFT JOIN playlist_items i ON i.playlist_id = p.id
|
||||
WHERE p.owner_id = @owner_id
|
||||
GROUP BY p.id
|
||||
ORDER BY p.kind = 'favorites' DESC, p.created_at, p.id;
|
||||
|
||||
-- name: MemberPlaylistCovers :many
|
||||
SELECT DISTINCT ON (i.playlist_id) i.playlist_id, i.cover_url, i.cover_key
|
||||
FROM playlist_items i
|
||||
JOIN playlists p ON p.id = i.playlist_id
|
||||
WHERE p.owner_id = @owner_id AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT NULL)
|
||||
ORDER BY i.playlist_id, i.position DESC;
|
||||
|
||||
-- name: LockFavorites :exec
|
||||
-- 新建歌单前锁住这个账号的“我喜欢”:同一个账号并发新建时依次计数,不会越过上限。
|
||||
SELECT id FROM playlists WHERE owner_id = @owner_id AND kind = 'favorites' FOR UPDATE;
|
||||
|
||||
-- name: CountPlaylists :one
|
||||
SELECT count(*) FROM playlists WHERE owner_id = @owner_id;
|
||||
|
||||
-- name: CreatePlaylist :one
|
||||
INSERT INTO playlists (owner_id, kind, name) VALUES (@owner_id, 'custom', @name)
|
||||
RETURNING id, created_at, updated_at;
|
||||
|
||||
-- name: PlaylistKind :one
|
||||
SELECT kind FROM playlists WHERE id = @id AND owner_id = @owner_id;
|
||||
|
||||
-- name: RenamePlaylist :execrows
|
||||
UPDATE playlists SET name = @name, updated_at = now() WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
|
||||
|
||||
-- name: DeletePlaylist :execrows
|
||||
DELETE FROM playlists WHERE id = @id AND owner_id = @owner_id AND kind = 'custom';
|
||||
|
||||
-- name: LockPlaylist :one
|
||||
-- 加入曲目前锁住歌单:并发加入时依次检查上限、分配排序值。
|
||||
SELECT item_seq FROM playlists WHERE id = @id AND owner_id = @owner_id FOR UPDATE;
|
||||
|
||||
-- name: PlaylistHasItem :one
|
||||
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = @playlist_id AND source = @source AND track_id = @track_id);
|
||||
|
||||
-- name: CountPlaylistItems :one
|
||||
SELECT count(*) FROM playlist_items WHERE playlist_id = @playlist_id;
|
||||
|
||||
-- name: InsertPlaylistItem :exec
|
||||
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
|
||||
VALUES (@playlist_id, @source, @track_id, @title, @artist, @album, @cover_url, @cover_key, @duration_ms, @page_url, @position);
|
||||
|
||||
-- name: SetPlaylistSeq :exec
|
||||
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: DeletePlaylistItems :exec
|
||||
WITH deleted AS (
|
||||
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;
|
||||
|
||||
-- name: TrackPlaylists :many
|
||||
SELECT p.id FROM playlists p
|
||||
JOIN playlist_items i ON i.playlist_id = p.id
|
||||
WHERE p.owner_id = @owner_id AND i.source = @source AND i.track_id = @track_id
|
||||
ORDER BY p.id;
|
||||
@@ -35,6 +35,10 @@ func (s *server) logout(ctx context.Context, c call) (any, error) {
|
||||
return nil, s.Accounts.Logout(ctx, bearer(c.r))
|
||||
}
|
||||
|
||||
func (s *server) logoutOtherSessions(ctx context.Context, c call) (any, error) {
|
||||
return nil, s.Accounts.LogoutOthers(ctx, c.member.ID, bearer(c.r))
|
||||
}
|
||||
|
||||
func (s *server) changePassword(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeChangePasswordRequest(c.body)
|
||||
if err != nil {
|
||||
@@ -71,9 +75,5 @@ func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, er
|
||||
}
|
||||
|
||||
func accountDTO(a identity.Account) protocol.Account {
|
||||
dto := protocol.Account{Username: a.Username}
|
||||
if a.Email != "" {
|
||||
dto.Email = &a.Email
|
||||
}
|
||||
return dto
|
||||
return protocol.Account{Username: a.Username}
|
||||
}
|
||||
@@ -22,12 +22,10 @@ import (
|
||||
"github.com/dihemoe/alisten/internal/app/identity"
|
||||
"github.com/dihemoe/alisten/internal/app/lobby"
|
||||
"github.com/dihemoe/alisten/internal/app/media"
|
||||
"github.com/dihemoe/alisten/internal/app/playlist"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"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"
|
||||
@@ -39,16 +37,15 @@ const (
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
Identity *identity.Service
|
||||
Accounts *identity.Accounts
|
||||
Playlists *playlist.Service
|
||||
Lobby *lobby.Service
|
||||
Admin *admin.Service
|
||||
Gateway *realtime.Gateway
|
||||
Catalog *catalog.Service
|
||||
Media *media.Library
|
||||
Playback *media.Playback
|
||||
Site *site.Service
|
||||
Identity *identity.Service
|
||||
Accounts *identity.Accounts
|
||||
Lobby *lobby.Service
|
||||
Admin *admin.Service
|
||||
Gateway *realtime.Gateway
|
||||
Catalog *catalog.Service
|
||||
Media *media.Library
|
||||
Playback *media.Playback
|
||||
Site *site.Service
|
||||
|
||||
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
|
||||
StorageURL string
|
||||
@@ -112,16 +109,7 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider,
|
||||
srv.handle(mux, protocol.RouteChangePassword, srv.changePassword)
|
||||
srv.handle(mux, protocol.RouteLogin, srv.login)
|
||||
srv.handle(mux, protocol.RouteLogout, srv.logout)
|
||||
srv.handle(mux, protocol.RouteListMyPlaylists, srv.listMyPlaylists)
|
||||
srv.handle(mux, protocol.RouteCreateMyPlaylist, srv.createMyPlaylist)
|
||||
srv.handle(mux, protocol.RouteRenameMyPlaylist, srv.renameMyPlaylist)
|
||||
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.RouteRemoveMyPlaylistItems, srv.removeMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteListMyTrackPlaylists, srv.listMyTrackPlaylists)
|
||||
srv.handle(mux, protocol.RouteCopyMyPlaylistItems, srv.copyMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteMoveMyPlaylistItems, srv.moveMyPlaylistItems)
|
||||
srv.handle(mux, protocol.RouteLogoutOtherSessions, srv.logoutOtherSessions)
|
||||
srv.handle(mux, protocol.RouteListRooms, srv.listRooms)
|
||||
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
|
||||
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
|
||||
@@ -306,7 +294,6 @@ var knownErrors = []struct {
|
||||
{admin.ErrUnrecognizedCookies, protocol.ErrorCodeInvalidArgument},
|
||||
{identity.ErrInvalidTicket, protocol.ErrorCodeUnauthorized},
|
||||
{identity.ErrBotSession, protocol.ErrorCodeUnauthorized},
|
||||
{identity.ErrInvalidEmail, protocol.ErrorCodeInvalidArgument},
|
||||
{identity.ErrInvalidCredentials, protocol.ErrorCodeInvalidCredentials},
|
||||
{identity.ErrWrongCurrentPassword, protocol.ErrorCodeInvalidCredentials},
|
||||
{identity.ErrAlreadyLoggedIn, protocol.ErrorCodeConflict},
|
||||
@@ -316,16 +303,6 @@ var knownErrors = []struct {
|
||||
{identity.ErrAccountNotFound, protocol.ErrorCodeNotFound},
|
||||
{port.ErrUsernameTaken, protocol.ErrorCodeConflict},
|
||||
{port.ErrAlreadyRegistered, protocol.ErrorCodeConflict},
|
||||
{port.ErrPlaylistNotFound, protocol.ErrorCodeNotFound},
|
||||
{port.ErrFavoritesFixed, protocol.ErrorCodeConflict},
|
||||
{playlist.ErrNotRegistered, protocol.ErrorCodeForbidden},
|
||||
{playlist.ErrInvalidName, protocol.ErrorCodeInvalidArgument},
|
||||
{playlist.ErrTooManyPlaylists, protocol.ErrorCodeConflict},
|
||||
{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},
|
||||
|
||||
@@ -299,7 +299,7 @@ func TestAbortHandlerIsLeftToNetHTTP(t *testing.T) {
|
||||
|
||||
type brokenProfiles struct{ *porttest.Store }
|
||||
|
||||
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string, *string) (member.Member, error) {
|
||||
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string) (member.Member, error) {
|
||||
return member.Member{}, errors.New("连接中断")
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ func (s *server) action(ctx context.Context, roomID string, by member.Member, bo
|
||||
// 协议解码保证要么只给出 track,要么只给出 source 和 keyword。
|
||||
func pick(d protocol.QueueAddRequest) realtime.Pick {
|
||||
if d.Track != nil {
|
||||
return realtime.Pick{Track: trackOf(*d.Track), Requester: deref(d.Requester)}
|
||||
t := track.Track{Source: track.Source(d.Track.Source), ID: d.Track.ID}
|
||||
if t.Source == track.Link {
|
||||
t.ID = track.LinkID(t.ID)
|
||||
}
|
||||
return realtime.Pick{Track: t, Requester: deref(d.Requester)}
|
||||
}
|
||||
return realtime.Pick{Source: track.Source(*d.Source), Keyword: *d.Keyword, Requester: deref(d.Requester)}
|
||||
}
|
||||
@@ -141,38 +145,18 @@ func (s *server) mediaURL(t track.Track) string {
|
||||
return "/media/" + string(t.Source) + "/" + url.PathEscape(t.ID)
|
||||
}
|
||||
|
||||
// 链接按点歌时的写法规范化:曲目表和歌单里的链接曲目都用规范化之后的 ID。
|
||||
func trackOf(d protocol.Track) track.Track {
|
||||
t := track.Track{Source: track.Source(d.Source), ID: d.ID}
|
||||
if t.Source == track.Link {
|
||||
t.ID = track.LinkID(t.ID)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// 封面在对象存储中的曲目,对外的封面地址由公开地址拼出。
|
||||
func (s *server) trackMeta(m track.Meta) protocol.TrackMeta {
|
||||
d := meta(m)
|
||||
d.CoverURL = s.coverURL(m.CoverURL, m.CoverKey)
|
||||
return d
|
||||
}
|
||||
|
||||
func (s *server) coverURL(url string, key track.ObjectKey) string {
|
||||
if key != "" {
|
||||
return s.StorageURL + "/" + string(key)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func (s *server) entry(e room.Entry) protocol.Entry {
|
||||
d := protocol.Entry{
|
||||
ID: strconv.FormatUint(uint64(e.ID), 10),
|
||||
Track: protocol.Track{Source: protocol.Source(e.Track.Source), ID: e.Track.ID},
|
||||
Meta: s.trackMeta(e.Meta),
|
||||
Meta: meta(e.Meta),
|
||||
Auto: e.Auto,
|
||||
Likes: int64(e.Likes),
|
||||
Status: protocol.EntryStatusPreparing,
|
||||
}
|
||||
if e.Meta.CoverKey != "" {
|
||||
d.Meta.CoverURL = s.StorageURL + "/" + string(e.Meta.CoverKey)
|
||||
}
|
||||
if !e.Auto {
|
||||
by := memberDTO(e.By)
|
||||
d.By = &by
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
"github.com/dihemoe/alisten/protocol"
|
||||
)
|
||||
|
||||
func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist {
|
||||
var cover *string
|
||||
if p.Cover != nil {
|
||||
url := s.coverURL(p.Cover.URL, p.Cover.Key)
|
||||
cover = &url
|
||||
}
|
||||
return protocol.MyPlaylist{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Favorites: p.Favorites,
|
||||
TrackCount: int64(p.TrackCount),
|
||||
CreatedAtMs: p.CreatedAt.UnixMilli(),
|
||||
UpdatedAtMs: p.UpdatedAt.UnixMilli(),
|
||||
CoverURL: cover,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) playlistItemDTO(item port.PlaylistItem) protocol.MyPlaylistItem {
|
||||
return protocol.MyPlaylistItem{
|
||||
Track: protocol.Track{Source: protocol.Source(item.Track.Source), ID: item.Track.ID},
|
||||
Meta: s.trackMeta(item.Meta),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *server) listMyPlaylists(ctx context.Context, c call) (any, error) {
|
||||
lists, err := s.Playlists.List(ctx, c.member.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]protocol.MyPlaylist, len(lists))
|
||||
for i, p := range lists {
|
||||
items[i] = s.playlistDTO(p)
|
||||
}
|
||||
return protocol.ListMyPlaylistsResponse{Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *server) createMyPlaylist(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeCreateMyPlaylistRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := s.Playlists.Create(ctx, c.member.ID, req.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.playlistDTO(p), nil
|
||||
}
|
||||
|
||||
func (s *server) renameMyPlaylist(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeRenameMyPlaylistRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.Playlists.Rename(ctx, c.member.ID, c.r.PathValue("id"), req.Name)
|
||||
}
|
||||
|
||||
func (s *server) deleteMyPlaylist(ctx context.Context, c call) (any, error) {
|
||||
return nil, s.Playlists.Delete(ctx, c.member.ID, c.r.PathValue("id"))
|
||||
}
|
||||
|
||||
func (s *server) listMyPlaylistItems(ctx context.Context, c call) (any, error) {
|
||||
list, err := s.Playlists.Items(ctx, c.member.ID, c.r.PathValue("id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]protocol.MyPlaylistItem, len(list))
|
||||
for i, item := range list {
|
||||
items[i] = s.playlistItemDTO(item)
|
||||
}
|
||||
return protocol.ListMyPlaylistItemsResponse{Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *server) addMyPlaylistItem(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeAddMyPlaylistItemRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := s.Playlists.Add(ctx, c.member.ID, c.r.PathValue("id"), trackOf(req.Track))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.AddMyPlaylistItemResponse{Item: s.playlistItemDTO(item)}, nil
|
||||
}
|
||||
|
||||
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"), tracksOf(req.Tracks))
|
||||
}
|
||||
|
||||
func (s *server) listMyTrackPlaylists(ctx context.Context, c call) (any, error) {
|
||||
req, err := protocol.DecodeListMyTrackPlaylistsRequest(c.body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := s.Playlists.Containing(ctx, c.member.ID, trackOf(req.Track))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return protocol.ListMyTrackPlaylistsResponse{PlaylistIds: ids}, nil
|
||||
}
|
||||
|
||||
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,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const accountByUsername = `-- name: AccountByUsername :one
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE username = $1
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash FROM members WHERE username = $1
|
||||
`
|
||||
|
||||
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
|
||||
@@ -23,14 +23,13 @@ func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Memb
|
||||
&i.CreatedAt,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createMember = `-- name: CreateMember :one
|
||||
INSERT INTO members (name) VALUES ($1)
|
||||
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
|
||||
RETURNING id, name, avatar_hash, created_at, username, password_hash
|
||||
`
|
||||
|
||||
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
|
||||
@@ -43,7 +42,6 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error)
|
||||
&i.CreatedAt,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -117,24 +115,8 @@ func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, er
|
||||
return password_hash, err
|
||||
}
|
||||
|
||||
const memberAccountProfile = `-- name: MemberAccountProfile :one
|
||||
SELECT username, email FROM members WHERE id = $1
|
||||
`
|
||||
|
||||
type MemberAccountProfileRow struct {
|
||||
Username *string
|
||||
Email *string
|
||||
}
|
||||
|
||||
func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) {
|
||||
row := q.db.QueryRow(ctx, memberAccountProfile, id)
|
||||
var i MemberAccountProfileRow
|
||||
err := row.Scan(&i.Username, &i.Email)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const memberByToken = `-- name: MemberByToken :one
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
|
||||
`
|
||||
|
||||
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
|
||||
@@ -147,11 +129,21 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
|
||||
&i.CreatedAt,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const memberUsername = `-- name: MemberUsername :one
|
||||
SELECT username FROM members WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) MemberUsername(ctx context.Context, id string) (*string, error) {
|
||||
row := q.db.QueryRow(ctx, memberUsername, id)
|
||||
var username *string
|
||||
err := row.Scan(&username)
|
||||
return username, err
|
||||
}
|
||||
|
||||
const registerMember = `-- name: RegisterMember :execrows
|
||||
UPDATE members SET username = $1, password_hash = $2
|
||||
WHERE id = $3 AND username IS NULL
|
||||
@@ -192,27 +184,20 @@ func (q *Queries) SetMemberPassword(ctx context.Context, arg SetMemberPasswordPa
|
||||
const updateMember = `-- name: UpdateMember :one
|
||||
UPDATE members
|
||||
SET name = coalesce($1, name),
|
||||
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END,
|
||||
email = CASE WHEN username IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
|
||||
WHERE id = $4
|
||||
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
|
||||
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END
|
||||
WHERE id = $3
|
||||
RETURNING id, name, avatar_hash, created_at, username, password_hash
|
||||
`
|
||||
|
||||
type UpdateMemberParams struct {
|
||||
Name *string
|
||||
AvatarHash *string
|
||||
Email *string
|
||||
ID string
|
||||
}
|
||||
|
||||
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希和邮箱传入空串表示清除。游客的邮箱总是 NULL。
|
||||
// 只覆盖传入的列,分别修改昵称和头像的两个并发请求因此不会互相还原;头像哈希传入空串表示清除。
|
||||
func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, updateMember,
|
||||
arg.Name,
|
||||
arg.AvatarHash,
|
||||
arg.Email,
|
||||
arg.ID,
|
||||
)
|
||||
row := q.db.QueryRow(ctx, updateMember, arg.Name, arg.AvatarHash, arg.ID)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
@@ -221,7 +206,6 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
|
||||
&i.CreatedAt,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -15,22 +15,6 @@ type Member struct {
|
||||
CreatedAt time.Time
|
||||
Username *string
|
||||
PasswordHash *string
|
||||
Email *string
|
||||
}
|
||||
|
||||
type PlaylistItem struct {
|
||||
PlaylistID string
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
Album *string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
DurationMs int64
|
||||
PageURL string
|
||||
Position int64
|
||||
AddedAt time.Time
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: playlists.sql
|
||||
|
||||
package query
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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
|
||||
`
|
||||
|
||||
func (q *Queries) CountPlaylistItems(ctx context.Context, playlistID string) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countPlaylistItems, playlistID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countPlaylists = `-- name: CountPlaylists :one
|
||||
SELECT count(*) FROM playlists WHERE owner_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountPlaylists(ctx context.Context, ownerID string) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countPlaylists, ownerID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createFavorites = `-- name: CreateFavorites :exec
|
||||
INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'favorites', '我喜欢')
|
||||
`
|
||||
|
||||
func (q *Queries) CreateFavorites(ctx context.Context, ownerID string) error {
|
||||
_, err := q.db.Exec(ctx, createFavorites, ownerID)
|
||||
return err
|
||||
}
|
||||
|
||||
const createPlaylist = `-- name: CreatePlaylist :one
|
||||
INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'custom', $2)
|
||||
RETURNING id, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreatePlaylistParams struct {
|
||||
OwnerID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type CreatePlaylistRow struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) (CreatePlaylistRow, error) {
|
||||
row := q.db.QueryRow(ctx, createPlaylist, arg.OwnerID, arg.Name)
|
||||
var i CreatePlaylistRow
|
||||
err := row.Scan(&i.ID, &i.CreatedAt, &i.UpdatedAt)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deletePlaylist = `-- name: DeletePlaylist :execrows
|
||||
DELETE FROM playlists WHERE id = $1 AND owner_id = $2 AND kind = 'custom'
|
||||
`
|
||||
|
||||
type DeletePlaylistParams struct {
|
||||
ID string
|
||||
OwnerID string
|
||||
}
|
||||
|
||||
func (q *Queries) DeletePlaylist(ctx context.Context, arg DeletePlaylistParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deletePlaylist, arg.ID, arg.OwnerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const deletePlaylistItems = `-- name: DeletePlaylistItems :exec
|
||||
WITH deleted AS (
|
||||
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 DeletePlaylistItemsParams struct {
|
||||
Sources []string
|
||||
TrackIds []string
|
||||
PlaylistID string
|
||||
}
|
||||
|
||||
func (q *Queries) DeletePlaylistItems(ctx context.Context, arg DeletePlaylistItemsParams) error {
|
||||
_, err := q.db.Exec(ctx, deletePlaylistItems, arg.Sources, arg.TrackIds, arg.PlaylistID)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertPlaylistItem = `-- name: InsertPlaylistItem :exec
|
||||
INSERT INTO playlist_items (playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
type InsertPlaylistItemParams struct {
|
||||
PlaylistID string
|
||||
Source string
|
||||
TrackID string
|
||||
Title string
|
||||
Artist *string
|
||||
Album *string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
DurationMs int64
|
||||
PageURL string
|
||||
Position int64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertPlaylistItem(ctx context.Context, arg InsertPlaylistItemParams) error {
|
||||
_, err := q.db.Exec(ctx, insertPlaylistItem,
|
||||
arg.PlaylistID,
|
||||
arg.Source,
|
||||
arg.TrackID,
|
||||
arg.Title,
|
||||
arg.Artist,
|
||||
arg.Album,
|
||||
arg.CoverURL,
|
||||
arg.CoverKey,
|
||||
arg.DurationMs,
|
||||
arg.PageURL,
|
||||
arg.Position,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const lockFavorites = `-- name: LockFavorites :exec
|
||||
SELECT id FROM playlists WHERE owner_id = $1 AND kind = 'favorites' FOR UPDATE
|
||||
`
|
||||
|
||||
// 新建歌单前锁住这个账号的“我喜欢”:同一个账号并发新建时依次计数,不会越过上限。
|
||||
func (q *Queries) LockFavorites(ctx context.Context, ownerID string) error {
|
||||
_, err := q.db.Exec(ctx, lockFavorites, ownerID)
|
||||
return err
|
||||
}
|
||||
|
||||
const lockPlaylist = `-- name: LockPlaylist :one
|
||||
SELECT item_seq FROM playlists WHERE id = $1 AND owner_id = $2 FOR UPDATE
|
||||
`
|
||||
|
||||
type LockPlaylistParams struct {
|
||||
ID string
|
||||
OwnerID string
|
||||
}
|
||||
|
||||
// 加入曲目前锁住歌单:并发加入时依次检查上限、分配排序值。
|
||||
func (q *Queries) LockPlaylist(ctx context.Context, arg LockPlaylistParams) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, lockPlaylist, arg.ID, arg.OwnerID)
|
||||
var item_seq int64
|
||||
err := row.Scan(&item_seq)
|
||||
return item_seq, err
|
||||
}
|
||||
|
||||
const memberPlaylistCovers = `-- name: MemberPlaylistCovers :many
|
||||
SELECT DISTINCT ON (i.playlist_id) i.playlist_id, i.cover_url, i.cover_key
|
||||
FROM playlist_items i
|
||||
JOIN playlists p ON p.id = i.playlist_id
|
||||
WHERE p.owner_id = $1 AND (i.cover_url IS NOT NULL OR i.cover_key IS NOT NULL)
|
||||
ORDER BY i.playlist_id, i.position DESC
|
||||
`
|
||||
|
||||
type MemberPlaylistCoversRow struct {
|
||||
PlaylistID string
|
||||
CoverURL *string
|
||||
CoverKey *string
|
||||
}
|
||||
|
||||
func (q *Queries) MemberPlaylistCovers(ctx context.Context, ownerID string) ([]MemberPlaylistCoversRow, error) {
|
||||
rows, err := q.db.Query(ctx, memberPlaylistCovers, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []MemberPlaylistCoversRow
|
||||
for rows.Next() {
|
||||
var i MemberPlaylistCoversRow
|
||||
if err := rows.Scan(&i.PlaylistID, &i.CoverURL, &i.CoverKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const memberPlaylists = `-- name: MemberPlaylists :many
|
||||
SELECT p.id, p.kind, p.name, p.created_at, p.updated_at, count(i.track_id)::bigint AS track_count
|
||||
FROM playlists p
|
||||
LEFT JOIN playlist_items i ON i.playlist_id = p.id
|
||||
WHERE p.owner_id = $1
|
||||
GROUP BY p.id
|
||||
ORDER BY p.kind = 'favorites' DESC, p.created_at, p.id
|
||||
`
|
||||
|
||||
type MemberPlaylistsRow struct {
|
||||
ID string
|
||||
Kind string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
TrackCount int64
|
||||
}
|
||||
|
||||
func (q *Queries) MemberPlaylists(ctx context.Context, ownerID string) ([]MemberPlaylistsRow, error) {
|
||||
rows, err := q.db.Query(ctx, memberPlaylists, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []MemberPlaylistsRow
|
||||
for rows.Next() {
|
||||
var i MemberPlaylistsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Kind,
|
||||
&i.Name,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.TrackCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const playlistHasItem = `-- name: PlaylistHasItem :one
|
||||
SELECT EXISTS (SELECT FROM playlist_items WHERE playlist_id = $1 AND source = $2 AND track_id = $3)
|
||||
`
|
||||
|
||||
type PlaylistHasItemParams struct {
|
||||
PlaylistID string
|
||||
Source string
|
||||
TrackID string
|
||||
}
|
||||
|
||||
func (q *Queries) PlaylistHasItem(ctx context.Context, arg PlaylistHasItemParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, playlistHasItem, arg.PlaylistID, arg.Source, arg.TrackID)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const playlistItems = `-- name: PlaylistItems :many
|
||||
SELECT playlist_id, source, track_id, title, artist, album, cover_url, cover_key, duration_ms, page_url, position, added_at FROM playlist_items WHERE playlist_id = $1 ORDER BY position DESC
|
||||
`
|
||||
|
||||
func (q *Queries) PlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error) {
|
||||
rows, err := q.db.Query(ctx, playlistItems, playlistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []PlaylistItem
|
||||
for rows.Next() {
|
||||
var i PlaylistItem
|
||||
if err := rows.Scan(
|
||||
&i.PlaylistID,
|
||||
&i.Source,
|
||||
&i.TrackID,
|
||||
&i.Title,
|
||||
&i.Artist,
|
||||
&i.Album,
|
||||
&i.CoverURL,
|
||||
&i.CoverKey,
|
||||
&i.DurationMs,
|
||||
&i.PageURL,
|
||||
&i.Position,
|
||||
&i.AddedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const playlistKind = `-- name: PlaylistKind :one
|
||||
SELECT kind FROM playlists WHERE id = $1 AND owner_id = $2
|
||||
`
|
||||
|
||||
type PlaylistKindParams struct {
|
||||
ID string
|
||||
OwnerID string
|
||||
}
|
||||
|
||||
func (q *Queries) PlaylistKind(ctx context.Context, arg PlaylistKindParams) (string, error) {
|
||||
row := q.db.QueryRow(ctx, playlistKind, arg.ID, arg.OwnerID)
|
||||
var kind string
|
||||
err := row.Scan(&kind)
|
||||
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'
|
||||
`
|
||||
|
||||
type RenamePlaylistParams struct {
|
||||
Name string
|
||||
ID string
|
||||
OwnerID string
|
||||
}
|
||||
|
||||
func (q *Queries) RenamePlaylist(ctx context.Context, arg RenamePlaylistParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, renamePlaylist, arg.Name, arg.ID, arg.OwnerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
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
|
||||
`
|
||||
|
||||
type SetPlaylistSeqParams struct {
|
||||
ItemSeq int64
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) SetPlaylistSeq(ctx context.Context, arg SetPlaylistSeqParams) error {
|
||||
_, 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
|
||||
}
|
||||
|
||||
const trackPlaylists = `-- name: TrackPlaylists :many
|
||||
SELECT p.id FROM playlists p
|
||||
JOIN playlist_items i ON i.playlist_id = p.id
|
||||
WHERE p.owner_id = $1 AND i.source = $2 AND i.track_id = $3
|
||||
ORDER BY p.id
|
||||
`
|
||||
|
||||
type TrackPlaylistsParams struct {
|
||||
OwnerID string
|
||||
Source string
|
||||
TrackID string
|
||||
}
|
||||
|
||||
func (q *Queries) TrackPlaylists(ctx context.Context, arg TrackPlaylistsParams) ([]string, error) {
|
||||
rows, err := q.db.Query(ctx, trackPlaylists, arg.OwnerID, arg.Source, arg.TrackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -43,8 +43,8 @@ func (s *Store) MemberByToken(ctx context.Context, tokenHash []byte) (member.Mem
|
||||
return memberOf(row), nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error) {
|
||||
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash, Email: email})
|
||||
func (s *Store) UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error) {
|
||||
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrMemberNotFound
|
||||
}
|
||||
@@ -58,31 +58,24 @@ func memberOf(row query.Member) member.Member {
|
||||
return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)}
|
||||
}
|
||||
|
||||
func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.AccountProfile, error) {
|
||||
row, err := s.q.MemberAccountProfile(ctx, memberID)
|
||||
func (s *Store) Username(ctx context.Context, memberID string) (string, error) {
|
||||
username, err := s.q.MemberUsername(ctx, memberID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrMemberNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
|
||||
return "", fmt.Errorf("读取成员 %s 的用户名:%w", memberID, err)
|
||||
}
|
||||
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, nil
|
||||
return orEmpty(username), nil
|
||||
}
|
||||
|
||||
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
n, err := q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
|
||||
if err != nil {
|
||||
return usernameError(err)
|
||||
}
|
||||
if n == 0 {
|
||||
return port.ErrAlreadyRegistered
|
||||
}
|
||||
return q.CreateFavorites(ctx, memberID)
|
||||
})
|
||||
n, err := s.q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
|
||||
if err != nil {
|
||||
return fmt.Errorf("注册成员 %s:%w", memberID, err)
|
||||
return fmt.Errorf("注册成员 %s:%w", memberID, usernameError(err))
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("注册成员 %s:%w", memberID, port.ErrAlreadyRegistered)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -103,7 +96,7 @@ func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Ac
|
||||
if err != nil {
|
||||
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
|
||||
}
|
||||
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil
|
||||
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), PasswordHash: orEmpty(row.PasswordHash)}, nil
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
|
||||
@@ -138,6 +131,14 @@ func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) ([][]byte, error) {
|
||||
deleted, err := s.q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("删除成员 %s 的会话:%w", memberID, err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
var deleted [][]byte
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
|
||||
@@ -59,13 +59,13 @@ func TestMemberAvatarIsNullWhenUnset(t *testing.T) {
|
||||
if hash := avatar(); hash != "NULL" {
|
||||
t.Errorf("新成员的 avatar_hash = %q,期望 NULL", hash)
|
||||
}
|
||||
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil); err != nil {
|
||||
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hash := avatar(); hash != "0bc83cb571cd1c50ba6f3e8a78ef1346" {
|
||||
t.Errorf("设置头像之后 avatar_hash = %q", hash)
|
||||
}
|
||||
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""), nil)
|
||||
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -78,14 +78,14 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
m := createMember(t, store, "alice", "游客-1a2b")
|
||||
|
||||
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil, nil)
|
||||
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (member.Member{ID: m.ID, Name: "甲"}); got != want {
|
||||
t.Errorf("只改昵称 = %#v,期望 %#v", got, want)
|
||||
}
|
||||
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil)
|
||||
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -96,74 +96,11 @@ func TestUpdateMemberKeepsOmittedFields(t *testing.T) {
|
||||
t.Errorf("修改后按 token 查到 %#v,期望 %#v", found, got)
|
||||
}
|
||||
|
||||
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil, nil); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("修改不存在的成员,错误 = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateMemberEmail(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := createMember(t, store, "alice", "游客-1a2b")
|
||||
guest := createMember(t, store, "guest", "游客-3c4d")
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
column := func(id string) string {
|
||||
t.Helper()
|
||||
var email string
|
||||
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", id).Scan(&email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return email
|
||||
}
|
||||
profile := func() port.AccountProfile {
|
||||
t.Helper()
|
||||
p, err := store.AccountProfile(t.Context(), alice.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("Alice@Example.COM")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := profile(); got != (port.AccountProfile{Username: "alice", Email: "Alice@Example.COM"}) {
|
||||
t.Errorf("保存邮箱之后 AccountProfile = %#v", got)
|
||||
}
|
||||
if account, err := store.AccountByUsername(t.Context(), "alice"); err != nil || account.Email != "Alice@Example.COM" {
|
||||
t.Errorf("AccountByUsername 的邮箱 = %q, %v", account.Email, err)
|
||||
}
|
||||
if _, err := store.UpdateMember(t.Context(), alice.ID, ptr("甲"), nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := profile().Email; got != "Alice@Example.COM" {
|
||||
t.Errorf("邮箱参数为 nil 时应保持原值,得到 %q", got)
|
||||
}
|
||||
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("b@example.com")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := profile().Email; got != "b@example.com" {
|
||||
t.Errorf("更新邮箱之后 = %q", got)
|
||||
}
|
||||
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := column(alice.ID); got != "NULL" {
|
||||
t.Errorf("清除邮箱之后 email 列 = %q,期望 NULL", got)
|
||||
}
|
||||
|
||||
if _, err := store.UpdateMember(t.Context(), guest.ID, nil, nil, ptr("g@example.com")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := column(guest.ID); got != "NULL" {
|
||||
t.Errorf("游客传入邮箱之后 email 列 = %q,期望 NULL", got)
|
||||
}
|
||||
if p, err := store.AccountProfile(t.Context(), guest.ID); err != nil || p != (port.AccountProfile{}) {
|
||||
t.Errorf("游客的 AccountProfile = %#v, %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func ownerOf(t *testing.T, store *Store, token string) string {
|
||||
t.Helper()
|
||||
m, err := store.MemberByToken(t.Context(), tokenHash(token))
|
||||
@@ -180,14 +117,14 @@ func TestRegisterMember(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := createMember(t, store, "alice", "游客-1a2b")
|
||||
bob := createMember(t, store, "bob", "游客-3c4d")
|
||||
if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{}) {
|
||||
t.Fatalf("游客的 AccountProfile = %#v, %v,期望两个字段都为空", profile, err)
|
||||
if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "" {
|
||||
t.Fatalf("游客的用户名 = %q, %v,期望空字符串", username, err)
|
||||
}
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{Username: "alice"}) {
|
||||
t.Errorf("注册之后的 AccountProfile = %#v, %v,期望只有用户名 alice", profile, err)
|
||||
if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "alice" {
|
||||
t.Errorf("注册之后的用户名 = %q, %v,期望 alice", username, err)
|
||||
}
|
||||
account, err := store.AccountByUsername(t.Context(), "alice")
|
||||
if want := (port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a"}); err != nil || account != want {
|
||||
@@ -202,7 +139,7 @@ func TestRegisterMember(t *testing.T) {
|
||||
if _, err := store.AccountByUsername(t.Context(), "nobody"); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("不存在的用户名 = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
if _, err := store.AccountProfile(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
if _, err := store.Username(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("不存在的成员 = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -223,15 +160,12 @@ func TestMemberSessions(t *testing.T) {
|
||||
t.Errorf("%s 属于成员 %q,期望 %s", s.token, got, alice.ID)
|
||||
}
|
||||
}
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1"))
|
||||
deleted, err := store.DeleteSessions(t.Context(), alice.ID, tokenHash("alice-1"))
|
||||
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
|
||||
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
|
||||
t.Errorf("DeleteSessions 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
|
||||
}
|
||||
if ownerOf(t, store, "alice-1") != alice.ID || ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice-3") != "" {
|
||||
t.Error("SetPassword 应只留下指定的会话")
|
||||
t.Error("DeleteSessions 应只留下指定的会话")
|
||||
}
|
||||
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -195,20 +195,28 @@ func TestTicketSessionsMigrationBindsTicketsToSessions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := provider.UpTo(ctx, 2); err != nil {
|
||||
if _, err := provider.UpTo(ctx, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var guest, bot, roomID string
|
||||
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name) VALUES ($1, '游客-1a2b') RETURNING id", tokenHash("guest")).Scan(&guest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, "INSERT INTO members (token_hash, name) VALUES ($1, 'bot') RETURNING id", tokenHash("bot")).Scan(&bot); err != nil {
|
||||
t.Fatal(err)
|
||||
var single, multiple, bot, roomID string
|
||||
for _, m := range []struct {
|
||||
id *string
|
||||
name string
|
||||
token []string
|
||||
}{{&single, "游客-1a2b", []string{"single"}}, {&multiple, "alice", []string{"multiple-1", "multiple-2"}}, {&bot, "bot", nil}} {
|
||||
if err := pool.QueryRow(ctx, "INSERT INTO members (name) VALUES ($1) RETURNING id", m.name).Scan(m.id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, token := range m.token {
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)", tokenHash(token), *m.id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := pool.QueryRow(ctx, "INSERT INTO rooms (kind, name, description, bot_member_id) VALUES ('persistent', '大厅', '', $1) RETURNING id", bot).Scan(&roomID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for ticket, memberID := range map[string]string{"guest-ticket": guest, "api-key": bot} {
|
||||
for ticket, memberID := range map[string]string{"single-ticket": single, "multiple-ticket": multiple, "api-key": bot} {
|
||||
if _, err := pool.Exec(ctx, "INSERT INTO room_tickets (token_hash, room_id, member_id) VALUES ($1, $2, $3)", tokenHash(ticket), roomID, memberID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -218,8 +226,11 @@ func TestTicketSessionsMigrationBindsTicketsToSessions(t *testing.T) {
|
||||
}
|
||||
|
||||
store := New(pool)
|
||||
if holder, err := store.TicketHolder(ctx, tokenHash("guest-ticket")); err != nil || !bytes.Equal(holder.SessionTokenHash, tokenHash("guest")) {
|
||||
t.Errorf("游客的票据 = %#v, %v,期望归到游客的会话", holder, err)
|
||||
if holder, err := store.TicketHolder(ctx, tokenHash("single-ticket")); err != nil || !bytes.Equal(holder.SessionTokenHash, tokenHash("single")) {
|
||||
t.Errorf("只有一个会话的成员的票据 = %#v, %v,期望归到这个会话", holder, err)
|
||||
}
|
||||
if _, err := store.TicketHolder(ctx, tokenHash("multiple-ticket")); !errors.Is(err, port.ErrTicketNotFound) {
|
||||
t.Errorf("有多个会话的成员的票据 = %v,期望删除", err)
|
||||
}
|
||||
if holder, err := store.TicketHolder(ctx, tokenHash("api-key")); err != nil || !holder.Bot || holder.SessionTokenHash != nil {
|
||||
t.Errorf("API Key 的票据 = %#v, %v,期望保留,没有会话", holder, err)
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
package postgres
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
var _ port.Playlists = (*Store)(nil)
|
||||
|
||||
const favoritesKind = "favorites"
|
||||
|
||||
func (s *Store) MemberPlaylists(ctx context.Context, ownerID string) ([]port.PlaylistRecord, error) {
|
||||
rows, err := s.q.MemberPlaylists(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取成员 %s 的歌单:%w", ownerID, err)
|
||||
}
|
||||
coverRows, err := s.q.MemberPlaylistCovers(ctx, ownerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取成员 %s 的歌单封面:%w", ownerID, err)
|
||||
}
|
||||
covers := make(map[string]*port.Cover, len(coverRows))
|
||||
for _, c := range coverRows {
|
||||
covers[c.PlaylistID] = &port.Cover{URL: orEmpty(c.CoverURL), Key: track.ObjectKey(orEmpty(c.CoverKey))}
|
||||
}
|
||||
lists := make([]port.PlaylistRecord, len(rows))
|
||||
for i, r := range rows {
|
||||
lists[i] = port.PlaylistRecord{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Favorites: r.Kind == favoritesKind,
|
||||
TrackCount: int(r.TrackCount),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
Cover: covers[r.ID],
|
||||
}
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
|
||||
var created query.CreatePlaylistRow
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
if err := q.LockFavorites(ctx, ownerID); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := q.CountPlaylists(ctx, ownerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n >= int64(limit) {
|
||||
return port.ErrPlaylistLimit
|
||||
}
|
||||
created, err = q.CreatePlaylist(ctx, query.CreatePlaylistParams{OwnerID: ownerID, Name: name})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, fmt.Errorf("成员 %s 新建歌单:%w", ownerID, err)
|
||||
}
|
||||
return port.PlaylistRecord{ID: created.ID, Name: name, CreatedAt: created.CreatedAt, UpdatedAt: created.UpdatedAt}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenamePlaylist(ctx context.Context, ownerID, id, name string) error {
|
||||
if !isUUID(id) {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
n, err := s.q.RenamePlaylist(ctx, query.RenamePlaylistParams{ID: id, OwnerID: ownerID, Name: name})
|
||||
if err == nil && n == 0 {
|
||||
err = s.unchangeable(ctx, ownerID, id)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("歌单 %s 改名:%w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePlaylist(ctx context.Context, ownerID, id string) error {
|
||||
if !isUUID(id) {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
n, err := s.q.DeletePlaylist(ctx, query.DeletePlaylistParams{ID: id, OwnerID: ownerID})
|
||||
if err == nil && n == 0 {
|
||||
err = s.unchangeable(ctx, ownerID, id)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除歌单 %s:%w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 改名或删除没有影响任何行:要么是“我喜欢”,要么歌单不属于这个成员。
|
||||
func (s *Store) unchangeable(ctx context.Context, ownerID, id string) error {
|
||||
kind, err := s.q.PlaylistKind(ctx, query.PlaylistKindParams{ID: id, OwnerID: ownerID})
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
return port.ErrPlaylistNotFound
|
||||
case err != nil:
|
||||
return err
|
||||
case kind == favoritesKind:
|
||||
return port.ErrFavoritesFixed
|
||||
}
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
|
||||
func (s *Store) owned(ctx context.Context, ownerID, id string) error {
|
||||
if !isUUID(id) {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
_, err := s.q.PlaylistKind(ctx, query.PlaylistKindParams{ID: id, OwnerID: ownerID})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) PlaylistItems(ctx context.Context, ownerID, id string) ([]port.PlaylistItem, error) {
|
||||
if err := s.owned(ctx, ownerID, id); err != nil {
|
||||
return nil, fmt.Errorf("读取歌单 %s:%w", id, err)
|
||||
}
|
||||
rows, err := s.q.PlaylistItems(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取歌单 %s 的曲目:%w", id, err)
|
||||
}
|
||||
items := make([]port.PlaylistItem, len(rows))
|
||||
for i, r := range rows {
|
||||
items[i] = port.PlaylistItem{
|
||||
Track: track.Track{Source: track.Source(r.Source), ID: r.TrackID},
|
||||
Meta: track.Meta{
|
||||
Title: r.Title,
|
||||
Artist: orEmpty(r.Artist),
|
||||
Album: orEmpty(r.Album),
|
||||
CoverURL: orEmpty(r.CoverURL),
|
||||
CoverKey: track.ObjectKey(orEmpty(r.CoverKey)),
|
||||
PageURL: r.PageURL,
|
||||
Duration: time.Duration(r.DurationMs) * time.Millisecond,
|
||||
},
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) AddPlaylistItem(ctx context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
|
||||
if !isUUID(id) {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
t, m := item.Track, item.Meta
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
seq, err := q.LockPlaylist(ctx, query.LockPlaylistParams{ID: id, OwnerID: ownerID})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
has, err := q.PlaylistHasItem(ctx, query.PlaylistHasItemParams{PlaylistID: id, Source: string(t.Source), TrackID: t.ID})
|
||||
if err != nil || has {
|
||||
return err
|
||||
}
|
||||
n, err := q.CountPlaylistItems(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n >= int64(limit) {
|
||||
return port.ErrPlaylistLimit
|
||||
}
|
||||
seq++
|
||||
if err := q.InsertPlaylistItem(ctx, query.InsertPlaylistItemParams{
|
||||
PlaylistID: id,
|
||||
Source: string(t.Source),
|
||||
TrackID: t.ID,
|
||||
Title: m.Title,
|
||||
Artist: nullable(m.Artist),
|
||||
Album: nullable(m.Album),
|
||||
CoverURL: nullable(m.CoverURL),
|
||||
CoverKey: nullable(string(m.CoverKey)),
|
||||
DurationMs: m.Duration.Milliseconds(),
|
||||
PageURL: m.PageURL,
|
||||
Position: seq,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.SetPlaylistSeq(ctx, query.SetPlaylistSeqParams{ID: id, ItemSeq: seq})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("向歌单 %s 加入 %s/%s:%w", id, t.Source, t.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error {
|
||||
err := s.owned(ctx, ownerID, id)
|
||||
if err == nil {
|
||||
sources, ids := splitTracks(tracks)
|
||||
err = s.q.DeletePlaylistItems(ctx, query.DeletePlaylistItemsParams{PlaylistID: id, Sources: sources, TrackIds: ids})
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("从歌单 %s 移除 %d 首歌:%w", id, len(tracks), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, error) {
|
||||
ids, err := s.q.TrackPlaylists(ctx, query.TrackPlaylistsParams{OwnerID: ownerID, Source: string(t.Source), TrackID: t.ID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询曲目 %s 所在的歌单:%w", t.ID, err)
|
||||
}
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
return ids, 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
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func registered(t *testing.T, store *Store, username string) member.Member {
|
||||
t.Helper()
|
||||
m := createMember(t, store, username, "游客-"+username)
|
||||
if err := store.Register(t.Context(), m.ID, username, "hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func favoritesOf(t *testing.T, store *Store, ownerID string) string {
|
||||
t.Helper()
|
||||
lists, err := store.MemberPlaylists(t.Context(), ownerID)
|
||||
if err != nil || len(lists) == 0 || !lists[0].Favorites {
|
||||
t.Fatalf("歌单 = %#v, %v,期望第一个是“我喜欢”", lists, err)
|
||||
}
|
||||
return lists[0].ID
|
||||
}
|
||||
|
||||
var (
|
||||
sunnyItem = port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: "186016"}, Meta: track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second}}
|
||||
clipItem = port.PlaylistItem{Track: track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}, Meta: track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: "https://example.com/a.mp3", Duration: time.Minute}}
|
||||
)
|
||||
|
||||
func TestRegisterCreatesFavorites(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
|
||||
if err != nil || len(lists) != 1 || !lists[0].Favorites || lists[0].Name != "我喜欢" || lists[0].TrackCount != 0 {
|
||||
t.Fatalf("注册之后的歌单 = %#v, %v,期望只有一个空的“我喜欢”", lists, err)
|
||||
}
|
||||
guest := createMember(t, store, "guest", "游客-1a2b")
|
||||
if lists, err := store.MemberPlaylists(t.Context(), guest.ID); err != nil || len(lists) != 0 {
|
||||
t.Errorf("游客的歌单 = %#v, %v,期望没有", lists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistLifecycle(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
bob := registered(t, store, "bob")
|
||||
favorites := favoritesOf(t, store, alice.ID)
|
||||
commute, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RenamePlaylist(t.Context(), alice.ID, commute.ID, "上班路上"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
|
||||
if err != nil || len(lists) != 2 || lists[1].ID != commute.ID || lists[1].Name != "上班路上" || lists[1].TrackCount != 0 || lists[1].Cover != nil {
|
||||
t.Errorf("歌单 = %#v, %v,期望“我喜欢”之后是改名后的 %s", lists, err, commute.ID)
|
||||
}
|
||||
if err := store.RenamePlaylist(t.Context(), alice.ID, favorites, "别的名字"); !errors.Is(err, port.ErrFavoritesFixed) {
|
||||
t.Errorf("“我喜欢”改名 = %v,期望 ErrFavoritesFixed", err)
|
||||
}
|
||||
if err := store.DeletePlaylist(t.Context(), alice.ID, favorites); !errors.Is(err, port.ErrFavoritesFixed) {
|
||||
t.Errorf("删除“我喜欢” = %v,期望 ErrFavoritesFixed", err)
|
||||
}
|
||||
for _, id := range []string{commute.ID, "not-a-uuid", "00000000-0000-7000-8000-000000000000"} {
|
||||
if err := store.RenamePlaylist(t.Context(), bob.ID, id, "x"); !errors.Is(err, port.ErrPlaylistNotFound) {
|
||||
t.Errorf("别人或不存在的歌单 %s 改名 = %v,期望 ErrPlaylistNotFound", id, err)
|
||||
}
|
||||
if err := store.DeletePlaylist(t.Context(), bob.ID, id); !errors.Is(err, port.ErrPlaylistNotFound) {
|
||||
t.Errorf("删除别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
|
||||
}
|
||||
if _, err := store.PlaylistItems(t.Context(), bob.ID, id); !errors.Is(err, port.ErrPlaylistNotFound) {
|
||||
t.Errorf("读取别人或不存在的歌单 %s = %v,期望 ErrPlaylistNotFound", id, err)
|
||||
}
|
||||
}
|
||||
if err := store.DeletePlaylist(t.Context(), alice.ID, commute.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 1 {
|
||||
t.Errorf("删除之后的歌单 = %#v, %v,期望只剩“我喜欢”", lists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePlaylistRemovesItsItems(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
commute, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AddPlaylistItem(t.Context(), alice.ID, commute.ID, sunnyItem, 1000); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.DeletePlaylist(t.Context(), alice.ID, commute.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := pool.QueryRow(t.Context(), `SELECT count(*) FROM playlist_items WHERE playlist_id = $1`, commute.ID).Scan(&count); err != nil || count != 0 {
|
||||
t.Errorf("删除歌单之后的曲目数 = %d, %v,期望 0", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHasOneFavorites(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
_, err := pool.Exec(t.Context(), `INSERT INTO playlists (owner_id, kind, name) VALUES ($1, 'favorites', '我喜欢')`, alice.ID)
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Code != uniqueViolation {
|
||||
t.Errorf("第二个“我喜欢” = %v,期望唯一约束错误", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistLimitCountsFavorites(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
if _, err := store.CreatePlaylist(t.Context(), alice.ID, "一", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.CreatePlaylist(t.Context(), alice.ID, "二", 2); !errors.Is(err, port.ErrPlaylistLimit) {
|
||||
t.Errorf("超过上限时 = %v,期望 ErrPlaylistLimit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistItems(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
bob := registered(t, store, "bob")
|
||||
favorites := favoritesOf(t, store, alice.ID)
|
||||
for _, item := range []port.PlaylistItem{sunnyItem, clipItem, sunnyItem} {
|
||||
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, err := store.PlaylistItems(t.Context(), alice.ID, favorites)
|
||||
if want := []port.PlaylistItem{clipItem, sunnyItem}; err != nil || !slices.Equal(items, want) {
|
||||
t.Errorf("曲目 = %#v, %v,期望新加入的在前、重复加入不变 %#v", items, err, want)
|
||||
}
|
||||
if lists, _ := store.MemberPlaylists(t.Context(), alice.ID); lists[0].TrackCount != 2 {
|
||||
t.Errorf("曲目数 = %d,期望 2", lists[0].TrackCount)
|
||||
}
|
||||
third := port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: "185811"}, Meta: track.Meta{Title: "七里香", PageURL: "https://music.163.com/#/song?id=185811"}}
|
||||
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, third, 2); !errors.Is(err, port.ErrPlaylistLimit) {
|
||||
t.Errorf("歌单满了时 = %v,期望 ErrPlaylistLimit", err)
|
||||
}
|
||||
if err := store.AddPlaylistItem(t.Context(), bob.ID, favorites, third, 2); !errors.Is(err, port.ErrPlaylistNotFound) {
|
||||
t.Errorf("加入别人的歌单 = %v,期望 ErrPlaylistNotFound", err)
|
||||
}
|
||||
for range 2 {
|
||||
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.RemovePlaylistItems(t.Context(), bob.ID, favorites, []track.Track{clipItem.Track}); !errors.Is(err, port.ErrPlaylistNotFound) {
|
||||
t.Errorf("从别人的歌单移除 = %v,期望 ErrPlaylistNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentPlaylistAddsRespectLimit(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
favorites := favoritesOf(t, store, alice.ID)
|
||||
var wg sync.WaitGroup
|
||||
for i := range 10 {
|
||||
wg.Go(func() {
|
||||
item := port.PlaylistItem{Track: track.Track{Source: track.NetEase, ID: strconv.Itoa(i)}, Meta: track.Meta{Title: "歌", PageURL: "https://music.163.com/"}}
|
||||
_ = store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 5)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if items, err := store.PlaylistItems(t.Context(), alice.ID, favorites); err != nil || len(items) != 5 {
|
||||
t.Errorf("并发加入之后有 %d 首, %v,期望正好 5 首", len(items), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentPlaylistCreatesRespectLimit(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
var wg sync.WaitGroup
|
||||
for i := range 10 {
|
||||
wg.Go(func() {
|
||||
_, _ = store.CreatePlaylist(t.Context(), alice.ID, "歌单"+strconv.Itoa(i), 5)
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
if lists, err := store.MemberPlaylists(t.Context(), alice.ID); err != nil || len(lists) != 5 {
|
||||
t.Errorf("并发新建之后有 %d 个歌单, %v,期望正好 5 个(含“我喜欢”)", len(lists), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistTimestamps(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
created, err := store.CreatePlaylist(t.Context(), alice.ID, "通勤", 50)
|
||||
if err != nil || created.CreatedAt.IsZero() || created.UpdatedAt.IsZero() || created.Cover != nil {
|
||||
t.Fatalf("新建 = %#v, %v,期望带创建和更新时间、没有封面", created, err)
|
||||
}
|
||||
read := func() port.PlaylistRecord {
|
||||
t.Helper()
|
||||
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
|
||||
if err != nil || len(lists) != 2 {
|
||||
t.Fatalf("歌单 = %#v, %v", lists, err)
|
||||
}
|
||||
return lists[1]
|
||||
}
|
||||
before := read()
|
||||
if !before.CreatedAt.Equal(created.CreatedAt) || !before.UpdatedAt.Equal(created.UpdatedAt) {
|
||||
t.Errorf("读出的时间 %v/%v,期望与新建时一致 %v/%v", before.CreatedAt, before.UpdatedAt, created.CreatedAt, created.UpdatedAt)
|
||||
}
|
||||
steps := []struct {
|
||||
name string
|
||||
do func() error
|
||||
changed bool
|
||||
}{
|
||||
{"改名", 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.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 {
|
||||
t.Fatalf("%s:%v", step.name, err)
|
||||
}
|
||||
after := read()
|
||||
if got := after.UpdatedAt.After(before.UpdatedAt); got != step.changed {
|
||||
t.Errorf("%s之后更新时间推进 = %v,期望 %v", step.name, got, step.changed)
|
||||
}
|
||||
if !after.CreatedAt.Equal(before.CreatedAt) {
|
||||
t.Errorf("%s之后创建时间变了:%v -> %v", step.name, before.CreatedAt, after.CreatedAt)
|
||||
}
|
||||
before = after
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistCoverIsTheFirstSongWithOne(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
favorites := favoritesOf(t, store, alice.ID)
|
||||
add := func(source track.Source, id, coverURL string, coverKey track.ObjectKey) {
|
||||
t.Helper()
|
||||
item := port.PlaylistItem{
|
||||
Track: track.Track{Source: source, ID: id},
|
||||
Meta: track.Meta{Title: id, CoverURL: coverURL, CoverKey: coverKey, PageURL: "https://example.com/" + id},
|
||||
}
|
||||
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, item, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := range 3 {
|
||||
add(track.NetEase, strconv.Itoa(i), "https://img.example/"+strconv.Itoa(i)+".jpg", "")
|
||||
}
|
||||
add(track.Link, "https://example.com/a.mp3", "", "covers/a.jpg")
|
||||
add(track.NetEase, "no-cover", "", "")
|
||||
lists, err := store.MemberPlaylists(t.Context(), alice.ID)
|
||||
if err != nil || len(lists) != 1 {
|
||||
t.Fatalf("歌单 = %#v, %v", lists, err)
|
||||
}
|
||||
if got, want := lists[0].Cover, (port.Cover{Key: "covers/a.jpg"}); got == nil || *got != want {
|
||||
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 TestTrackPlaylistsOnlyListsOwnPlaylists(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := registered(t, store, "alice")
|
||||
bob := registered(t, store, "bob")
|
||||
favorites := favoritesOf(t, store, alice.ID)
|
||||
commute := filled(t, store, alice.ID, "通勤", "1")
|
||||
filled(t, store, alice.ID, "空", "2")
|
||||
bobFavorites := favoritesOf(t, store, bob.ID)
|
||||
if err := store.AddPlaylistItem(t.Context(), alice.ID, favorites, songItem("1"), 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AddPlaylistItem(t.Context(), bob.ID, bobFavorites, songItem("1"), 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids, err := store.TrackPlaylists(t.Context(), alice.ID, songItem("1").Track)
|
||||
if want := []string{favorites, commute}; err != nil || !slices.Equal(ids, want) {
|
||||
t.Errorf("所在的歌单 = %v, %v,期望只有本人的 %v", ids, err, want)
|
||||
}
|
||||
ids, err = store.TrackPlaylists(t.Context(), alice.ID, songItem("3").Track)
|
||||
if err != nil || ids == nil || len(ids) != 0 {
|
||||
t.Errorf("不在任何歌单里的歌 = %#v, %v,期望空切片", ids, 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("移动之后更新时间没有推进")
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func TestTicketHolder(t *testing.T) {
|
||||
t.Errorf("TicketHolder = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
|
||||
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil, nil)
|
||||
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -83,17 +83,14 @@ func TestTicketsBelongToSessions(t *testing.T) {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "old"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := store.SetPassword(t.Context(), alice.ID, "new", tokenHash("alice-1"))
|
||||
deleted, err := store.DeleteSessions(t.Context(), alice.ID, tokenHash("alice-1"))
|
||||
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
|
||||
t.Errorf("SetPassword 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
|
||||
t.Errorf("DeleteSessions 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
|
||||
}
|
||||
if !valid("alice-1") || valid("alice-2") || valid("alice-3") {
|
||||
t.Error("删除会话之后,只有保留的会话的票据仍然有效")
|
||||
}
|
||||
if err := store.ReplaceSession(t.Context(), tokenHash("guest-4"), alice.ID, tokenHash("alice-4"), "new"); err != nil {
|
||||
if err := store.ReplaceSession(t.Context(), tokenHash("guest-4"), alice.ID, tokenHash("alice-4"), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if valid("guest-4") {
|
||||
|
||||
@@ -35,7 +35,6 @@ const (
|
||||
|
||||
type Account struct {
|
||||
Username string
|
||||
Email string
|
||||
}
|
||||
|
||||
// 删除会话之后,用这些会话的票据建立的房间连接立即断开;数据已经修改而断开的通知没有发出时返回错误,
|
||||
@@ -54,11 +53,11 @@ func NewAccounts(members port.Members, site port.Site, quota port.AccountQuota,
|
||||
|
||||
// 游客没有账号,ok 为 false。
|
||||
func (a *Accounts) Account(ctx context.Context, memberID string) (account Account, ok bool, err error) {
|
||||
profile, err := a.members.AccountProfile(ctx, memberID)
|
||||
username, err := a.members.Username(ctx, memberID)
|
||||
if err != nil {
|
||||
return Account{}, false, err
|
||||
}
|
||||
return Account{Username: profile.Username, Email: profile.Email}, profile.Username != "", nil
|
||||
return Account{Username: username}, username != "", nil
|
||||
}
|
||||
|
||||
// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。
|
||||
@@ -116,7 +115,7 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke
|
||||
if err := a.gateway.RevokeSessions(ctx, [][]byte{guest}); err != nil {
|
||||
a.logger.WarnContext(ctx, "登录后断开游客会话的通知没有发出", "error", err)
|
||||
}
|
||||
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, nil
|
||||
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username}, nil
|
||||
}
|
||||
|
||||
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
|
||||
@@ -156,13 +155,20 @@ func (a *Accounts) Logout(ctx context.Context, token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Accounts) LogoutOthers(ctx context.Context, memberID, token string) error {
|
||||
deleted, err := a.members.DeleteSessions(ctx, memberID, hashSecret(token))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.gateway.RevokeSessions(ctx, deleted)
|
||||
}
|
||||
|
||||
// 修改成功后,账号除了发出请求的这个会话,其他会话都失效。
|
||||
func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentPassword, newPassword string) error {
|
||||
profile, err := a.members.AccountProfile(ctx, memberID)
|
||||
username, err := a.members.Username(ctx, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username := profile.Username
|
||||
if username == "" {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
|
||||
@@ -147,37 +147,6 @@ func TestRegister(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountKeepsEmail(t *testing.T) {
|
||||
a, s, _, _, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
guest := createMember(t, s)
|
||||
|
||||
if _, err := s.UpdateProfile(t.Context(), alice.Member.ID, Profile{Email: ptr(" Alice@Example.COM ")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok, err := a.Account(t.Context(), alice.Member.ID); err != nil || !ok || got != (Account{Username: "alice", Email: "Alice@Example.COM"}) {
|
||||
t.Errorf("保存邮箱之后 Account = %#v, %v, %v", got, ok, err)
|
||||
}
|
||||
other := createMember(t, s)
|
||||
_, account, err := a.Login(t.Context(), other.Member, other.MemberToken, clientIP, "alice", "correct horse")
|
||||
if err != nil || account != (Account{Username: "alice", Email: "Alice@Example.COM"}) {
|
||||
t.Errorf("另一台设备登录得到 %#v, %v,期望带邮箱", account, err)
|
||||
}
|
||||
if _, err := s.UpdateProfile(t.Context(), alice.Member.ID, Profile{Email: ptr(" ")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _, _ := a.Account(t.Context(), alice.Member.ID); got.Email != "" {
|
||||
t.Errorf("清除之后邮箱 = %q", got.Email)
|
||||
}
|
||||
|
||||
if _, err := s.UpdateProfile(t.Context(), guest.Member.ID, Profile{Email: ptr("g@example.com")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
|
||||
t.Error("游客设置邮箱之后仍然没有账号")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuestHasNoAccount(t *testing.T) {
|
||||
a, s, _, _, _ := newAccounts(t)
|
||||
if _, ok, err := a.Account(t.Context(), createMember(t, s).Member.ID); err != nil || ok {
|
||||
@@ -284,9 +253,29 @@ func TestLoginRejected(t *testing.T) {
|
||||
func TestLogout(t *testing.T) {
|
||||
a, s, store, _, revoked := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
guest := createMember(t, s)
|
||||
other, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := createRoom(t, store, "")
|
||||
aliceTicket := issueTicket(t, s, alice, id)
|
||||
aliceTicket, otherTicket := issueTicket(t, s, alice, id), issueTicket(t, s, other, id)
|
||||
revoked()
|
||||
if err := a.LogoutOthers(t.Context(), alice.Member.ID, alice.MemberToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := tokenOwner(t, store, alice.MemberToken); !ok {
|
||||
t.Error("退出其他设备不影响发出请求的会话")
|
||||
}
|
||||
if _, ok := tokenOwner(t, store, other.MemberToken); ok {
|
||||
t.Error("退出其他设备之后其他会话应失效")
|
||||
}
|
||||
if got := revoked(); !sameHashes(got, other.MemberToken) {
|
||||
t.Errorf("退出其他设备之后广播的会话 = %x,期望其他设备的会话", got)
|
||||
}
|
||||
if !ticketValid(t, s, aliceTicket) || ticketValid(t, s, otherTicket) {
|
||||
t.Error("退出其他设备之后只有其他设备的票据失效")
|
||||
}
|
||||
if err := a.Logout(t.Context(), alice.MemberToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -410,6 +399,7 @@ func TestOtherDeviceOperationsFailWhenRevocationFails(t *testing.T) {
|
||||
name string
|
||||
op func() error
|
||||
}{
|
||||
{"LogoutOthers", func() error { return a.LogoutOthers(t.Context(), alice.Member.ID, alice.MemberToken) }},
|
||||
{"ChangePassword", func() error {
|
||||
return a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "correct horse")
|
||||
}},
|
||||
|
||||
@@ -3,8 +3,6 @@ package identity
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
@@ -17,11 +15,8 @@ var (
|
||||
ErrInvalidTicket = errors.New("票据无效")
|
||||
ErrWrongPassword = errors.New("房间密码错误")
|
||||
ErrBotSession = errors.New("API Key 只能用于 HTTP 接口")
|
||||
ErrInvalidEmail = errors.New("email:格式不正确,应形如 name@example.com")
|
||||
)
|
||||
|
||||
var emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
|
||||
|
||||
type Service struct {
|
||||
members port.Members
|
||||
rooms port.RoomDirectory
|
||||
@@ -60,21 +55,17 @@ func (s *Service) Authenticate(ctx context.Context, memberToken string) (member.
|
||||
|
||||
type Profile struct {
|
||||
Name *string
|
||||
// 用来计算头像哈希;已注册的账号同时保存它。空字符串表示清除。
|
||||
// 只用来计算头像哈希,不保存;空字符串表示清除头像。
|
||||
Email *string
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, memberID string, p Profile) (member.Member, error) {
|
||||
var avatar, email *string
|
||||
var avatar *string
|
||||
if p.Email != nil {
|
||||
trimmed := strings.TrimSpace(*p.Email)
|
||||
if trimmed != "" && !emailPattern.MatchString(trimmed) {
|
||||
return member.Member{}, ErrInvalidEmail
|
||||
}
|
||||
hash := avatarHash(trimmed)
|
||||
avatar, email = &hash, &trimmed
|
||||
hash := avatarHash(*p.Email)
|
||||
avatar = &hash
|
||||
}
|
||||
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, email)
|
||||
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar)
|
||||
if err != nil {
|
||||
return member.Member{}, err
|
||||
}
|
||||
@@ -106,7 +97,7 @@ func (s *Service) IssueTicket(ctx context.Context, memberID, memberToken, roomID
|
||||
return "", ErrWrongPassword
|
||||
}
|
||||
if errors.Is(err, port.ErrSessionNotFound) {
|
||||
// 会话在鉴权之后被删除(例如另一台设备退出登录或修改了密码),此时成员 token 确实已经失效。
|
||||
// 会话在鉴权之后被删除(例如另一台设备退出了其他设备),此时成员 token 确实已经失效。
|
||||
return "", ErrInvalidMemberToken
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -96,23 +96,6 @@ func TestUpdateProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfileRejectsMalformedEmail(t *testing.T) {
|
||||
s, _ := newService(t)
|
||||
id := createMember(t, s).Member.ID
|
||||
if _, err := s.UpdateProfile(t.Context(), id, Profile{Email: ptr("alice@example.com")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, email := range []string{"a@b@c.com", "alice@example", "a lice@example.com", "alice@exa mple.com", "@", "@example.com", "alice@.", "alice"} {
|
||||
if _, err := s.UpdateProfile(t.Context(), id, Profile{Email: ptr(email)}); !errors.Is(err, ErrInvalidEmail) {
|
||||
t.Errorf("邮箱 %q 的错误 = %v,期望 ErrInvalidEmail", email, err)
|
||||
}
|
||||
}
|
||||
if m, _ := s.UpdateProfile(t.Context(), id, Profile{}); m.AvatarHash != "c160f8cc69a4f0bf2b0362752353d060" {
|
||||
t.Errorf("被拒绝的邮箱不应改动头像:%#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
type noMemberBroadcast struct {
|
||||
port.Bus
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/catalog"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPlaylists = 50
|
||||
maxItems = 1000
|
||||
maxNameLength = 50
|
||||
// 收藏可能要向音源查询元数据:额度防止批量请求让共用的音源账号被平台限流。
|
||||
addBurst = 30
|
||||
addRefill = 2 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotRegistered = errors.New("注册账号之后才能使用歌单")
|
||||
ErrInvalidName = errors.New("name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符")
|
||||
ErrTooManyPlaylists = errors.New("最多只能建 50 个歌单")
|
||||
ErrPlaylistFull = errors.New("一个歌单最多 1000 首歌")
|
||||
ErrTrackNotReady = errors.New("这首歌还在准备,准备好之后再保存")
|
||||
ErrTooFast = errors.New("操作太频繁,请稍后再试。")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
playlists port.Playlists
|
||||
members port.Members
|
||||
tracks port.Tracks
|
||||
catalog *catalog.Service
|
||||
quota port.AccountQuota
|
||||
}
|
||||
|
||||
func New(playlists port.Playlists, members port.Members, tracks port.Tracks, catalog *catalog.Service, quota port.AccountQuota) *Service {
|
||||
return &Service{playlists: playlists, members: members, tracks: tracks, catalog: catalog, quota: quota}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, memberID string) ([]port.PlaylistRecord, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.playlists.MemberPlaylists(ctx, memberID)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, memberID, name string) (port.PlaylistRecord, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
name, err := validName(name)
|
||||
if err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
p, err := s.playlists.CreatePlaylist(ctx, memberID, name, maxPlaylists)
|
||||
if errors.Is(err, port.ErrPlaylistLimit) {
|
||||
return port.PlaylistRecord{}, ErrTooManyPlaylists
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Service) Rename(ctx context.Context, memberID, id, name string) error {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := validName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.playlists.RenamePlaylist(ctx, memberID, id, name)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, memberID, id string) error {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.playlists.DeletePlaylist(ctx, memberID, id)
|
||||
}
|
||||
|
||||
func (s *Service) Items(ctx context.Context, memberID, id string) ([]port.PlaylistItem, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.playlists.PlaylistItems(ctx, memberID, id)
|
||||
}
|
||||
|
||||
// 链接曲目的 t.ID 必须已经按 track.LinkID 规范化。
|
||||
func (s *Service) Add(ctx context.Context, memberID, id string, t track.Track) (port.PlaylistItem, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
wait, err := s.quota.Take(ctx, "playlist:member:"+memberID, addBurst, addRefill)
|
||||
if err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
if wait > 0 {
|
||||
return port.PlaylistItem{}, ErrTooFast
|
||||
}
|
||||
meta, err := s.meta(ctx, t)
|
||||
if err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
item := port.PlaylistItem{Track: t, Meta: meta}
|
||||
err = s.playlists.AddPlaylistItem(ctx, memberID, id, item, maxItems)
|
||||
if errors.Is(err, port.ErrPlaylistLimit) {
|
||||
return port.PlaylistItem{}, ErrPlaylistFull
|
||||
}
|
||||
if err != nil {
|
||||
return port.PlaylistItem{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
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.RemovePlaylistItems(ctx, memberID, id, tracks)
|
||||
}
|
||||
|
||||
func (s *Service) Containing(ctx context.Context, memberID string, t track.Track) ([]string, error) {
|
||||
if err := s.registered(ctx, memberID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.playlists.TrackPlaylists(ctx, memberID, t)
|
||||
}
|
||||
|
||||
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 {
|
||||
profile, err := s.members.AccountProfile(ctx, memberID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profile.Username == "" {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 链接曲目准备好之前没有元数据,音源也查不到。
|
||||
func (s *Service) meta(ctx context.Context, t track.Track) (track.Meta, error) {
|
||||
if t.Source != track.Link {
|
||||
return s.catalog.TrackMeta(ctx, t)
|
||||
}
|
||||
p, ok, err := s.tracks.PreparedTrack(ctx, t)
|
||||
if err != nil {
|
||||
return track.Meta{}, err
|
||||
}
|
||||
if !ok {
|
||||
return track.Meta{}, ErrTrackNotReady
|
||||
}
|
||||
return p.Meta, nil
|
||||
}
|
||||
|
||||
func validName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if utf8.RuneCountInString(name) > maxNameLength || strings.ContainsFunc(name, unicode.IsControl) || !strings.ContainsFunc(name, visible) {
|
||||
return "", ErrInvalidName
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// unicode.IsGraphic 把 Zs 类的空白也算作 graphic。
|
||||
func visible(r rune) bool {
|
||||
return unicode.IsGraphic(r) && !unicode.IsSpace(r)
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package playlist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/catalog"
|
||||
"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"
|
||||
)
|
||||
|
||||
var (
|
||||
sunny = track.Track{Source: track.NetEase, ID: "186016"}
|
||||
sunnyMeta = track.Meta{Title: "晴天", Artist: "周杰伦", Album: "叶惠美", CoverURL: "https://p1.music.126.net/a.jpg", PageURL: "https://music.163.com/#/song?id=186016", Duration: 269 * time.Second}
|
||||
clip = track.Track{Source: track.Link, ID: "https://example.com/a.mp3"}
|
||||
)
|
||||
|
||||
type tracksTable map[track.Track]port.PreparedTrack
|
||||
|
||||
func (t tracksTable) PreparedTrack(_ context.Context, k track.Track) (port.PreparedTrack, bool, error) {
|
||||
p, ok := t[k]
|
||||
return p, ok, nil
|
||||
}
|
||||
|
||||
func (tracksTable) SavePreparedTrack(context.Context, track.Track, port.PreparedTrack) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type noHistory struct{}
|
||||
|
||||
func (noHistory) AutoDJSeeds(context.Context, string, bool) ([]track.Track, error) { return nil, nil }
|
||||
|
||||
func (noHistory) AutoDJExclusions(context.Context, string) ([]track.Track, error) { return nil, nil }
|
||||
|
||||
type roomQuota struct{}
|
||||
|
||||
func (roomQuota) TakeSearch(context.Context, string, int, time.Duration) (time.Duration, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (roomQuota) TakePick(context.Context, string, int, time.Duration) (time.Duration, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type accountQuota struct {
|
||||
exhausted bool
|
||||
keys []string
|
||||
}
|
||||
|
||||
func (q *accountQuota) Take(_ context.Context, key string, _ int, _ time.Duration) (time.Duration, error) {
|
||||
q.keys = append(q.keys, key)
|
||||
if q.exhausted {
|
||||
return time.Second, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
svc *Service
|
||||
store *porttest.Store
|
||||
tracks tracksTable
|
||||
quota *accountQuota
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) fixture {
|
||||
t.Helper()
|
||||
store := porttest.New()
|
||||
netease := porttest.NewSource(track.NetEase)
|
||||
netease.Add(sunny.ID, sunnyMeta, "", nil)
|
||||
bilibili := porttest.NewBilibili()
|
||||
tracks := tracksTable{}
|
||||
music := catalog.New(netease, porttest.NewSource(track.QQ), bilibili, bilibili, tracks, noHistory{}, store, roomQuota{})
|
||||
q := &accountQuota{}
|
||||
return fixture{svc: New(store, store, tracks, music, q), store: store, tracks: tracks, quota: q}
|
||||
}
|
||||
|
||||
func (f fixture) member(t *testing.T, username string) member.Member {
|
||||
t.Helper()
|
||||
hash := sha256.Sum256([]byte(username))
|
||||
m, err := f.store.CreateMember(t.Context(), hash[:], "游客-"+username)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if username != "" {
|
||||
if err := f.store.Register(t.Context(), m.ID, username, "hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (f fixture) favorites(t *testing.T, memberID string) string {
|
||||
t.Helper()
|
||||
lists, err := f.svc.List(t.Context(), memberID)
|
||||
if err != nil || len(lists) == 0 || !lists[0].Favorites {
|
||||
t.Fatalf("歌单 = %#v, %v", lists, err)
|
||||
}
|
||||
return lists[0].ID
|
||||
}
|
||||
|
||||
func TestGuestCannotUsePlaylists(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
guest := f.member(t, "")
|
||||
if _, err := f.svc.List(t.Context(), guest.ID); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客读取歌单 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, err := f.svc.Create(t.Context(), guest.ID, "通勤"); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客新建歌单 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, err := f.svc.Add(t.Context(), guest.ID, "any", sunny); !errors.Is(err, ErrNotRegistered) {
|
||||
t.Errorf("游客收藏 = %v,期望 ErrNotRegistered", err)
|
||||
}
|
||||
if _, err := f.svc.Containing(t.Context(), guest.ID, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidatesName(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
p, err := f.svc.Create(t.Context(), alice.ID, " 通勤 ")
|
||||
if err != nil || p.Name != "通勤" {
|
||||
t.Fatalf("Create = %#v, %v,期望去掉首尾空白", p, err)
|
||||
}
|
||||
for _, name := range []string{"", " ", strings.Repeat("歌", 51), "通\n勤", "\u200b"} {
|
||||
if _, err := f.svc.Create(t.Context(), alice.ID, name); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("Create(%q) = %v,期望 ErrInvalidName", name, err)
|
||||
}
|
||||
if err := f.svc.Rename(t.Context(), alice.ID, p.ID, name); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("Rename(%q) = %v,期望 ErrInvalidName", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := f.svc.Create(t.Context(), alice.ID, strings.Repeat("歌", 50)); err != nil {
|
||||
t.Errorf("50 个字的歌单名 = %v,期望可以", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStopsAtFiftyPlaylists(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
for i := range 49 {
|
||||
if _, err := f.svc.Create(t.Context(), alice.ID, "歌单"+strconv.Itoa(i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := f.svc.Create(t.Context(), alice.ID, "第 51 个"); !errors.Is(err, ErrTooManyPlaylists) {
|
||||
t.Errorf("第 51 个歌单 = %v,期望 ErrTooManyPlaylists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTakesMetaFromTheServer(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
item, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny)
|
||||
if want := (port.PlaylistItem{Track: sunny, Meta: sunnyMeta}); err != nil || item != want {
|
||||
t.Fatalf("Add = %#v, %v,期望 %#v", item, err, want)
|
||||
}
|
||||
if items, err := f.svc.Items(t.Context(), alice.ID, favorites); err != nil || !slices.Equal(items, []port.PlaylistItem{item}) {
|
||||
t.Errorf("Items = %#v, %v", items, err)
|
||||
}
|
||||
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, []track.Track{sunny}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items, _ := f.svc.Items(t.Context(), alice.ID, favorites); len(items) != 0 {
|
||||
t.Errorf("移除之后 = %#v,期望为空", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddLinkNeedsPreparedTrack(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, clip); !errors.Is(err, ErrTrackNotReady) {
|
||||
t.Errorf("没准备过的链接 = %v,期望 ErrTrackNotReady", err)
|
||||
}
|
||||
meta := track.Meta{Title: "a.mp3", CoverKey: "covers/abc", PageURL: clip.ID, Duration: time.Minute}
|
||||
f.tracks[clip] = port.PreparedTrack{Meta: meta}
|
||||
if item, err := f.svc.Add(t.Context(), alice.ID, favorites, clip); err != nil || item.Meta != meta {
|
||||
t.Errorf("准备过的链接 = %#v, %v,期望用曲目表里的元数据", item, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddIsRateLimited(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
f.quota.exhausted = true
|
||||
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); !errors.Is(err, ErrTooFast) {
|
||||
t.Errorf("额度用完时 = %v,期望 ErrTooFast", err)
|
||||
}
|
||||
if items, _ := f.svc.Items(t.Context(), alice.ID, favorites); len(items) != 0 {
|
||||
t.Errorf("额度用完时不应加入:%#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
type fullPlaylists struct{ *porttest.Store }
|
||||
|
||||
func (fullPlaylists) AddPlaylistItem(context.Context, string, string, port.PlaylistItem, int) error {
|
||||
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")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
f.svc.playlists = fullPlaylists{f.store}
|
||||
if _, err := f.svc.Add(t.Context(), alice.ID, favorites, sunny); !errors.Is(err, ErrPlaylistFull) {
|
||||
t.Errorf("歌单满了时 = %v,期望 ErrPlaylistFull", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFavoritesStayFixed(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
favorites := f.favorites(t, alice.ID)
|
||||
if err := f.svc.Rename(t.Context(), alice.ID, favorites, "别的"); !errors.Is(err, port.ErrFavoritesFixed) {
|
||||
t.Errorf("“我喜欢”改名 = %v,期望 ErrFavoritesFixed", err)
|
||||
}
|
||||
if err := f.svc.Delete(t.Context(), alice.ID, favorites); !errors.Is(err, port.ErrFavoritesFixed) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -19,26 +19,20 @@ var (
|
||||
type Account struct {
|
||||
Member member.Member
|
||||
Username string
|
||||
Email string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
// 游客的两个字段都为空。
|
||||
type AccountProfile struct {
|
||||
Username string
|
||||
Email string
|
||||
}
|
||||
|
||||
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。一个成员 token 是成员的一个会话,同一个成员可以有多个会话。
|
||||
// 删除会话时,签发给这个会话的票据一并删除。
|
||||
type Members interface {
|
||||
// 创建游客和它的第一个会话。
|
||||
CreateMember(ctx context.Context, tokenHash []byte, name string) (member.Member, error)
|
||||
MemberByToken(ctx context.Context, tokenHash []byte) (member.Member, error)
|
||||
// name、avatarHash 或 email 为 nil 时保持原值,email 为空字符串表示清除。游客的 email 不保存。
|
||||
UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error)
|
||||
AccountProfile(ctx context.Context, memberID string) (AccountProfile, error)
|
||||
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。同时为这个账号创建“我喜欢”。
|
||||
// name 或 avatarHash 为 nil 时保持原值。
|
||||
UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error)
|
||||
// 游客的用户名是空字符串。
|
||||
Username(ctx context.Context, memberID string) (string, error)
|
||||
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。
|
||||
Register(ctx context.Context, memberID, username, passwordHash string) error
|
||||
AccountByUsername(ctx context.Context, username string) (Account, error)
|
||||
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
|
||||
@@ -46,7 +40,8 @@ type Members interface {
|
||||
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
|
||||
ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error
|
||||
DeleteSession(ctx context.Context, tokenHash []byte) error
|
||||
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。
|
||||
// 返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
|
||||
// 删除成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。返回被删除的会话的 token 哈希。
|
||||
DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) ([][]byte, error)
|
||||
// 在同一个事务中修改已注册成员的密码哈希,并像 DeleteSessions 一样删除会话。成员是游客时返回 ErrMemberNotFound。
|
||||
SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/domain/track"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPlaylistNotFound = errors.New("歌单不存在")
|
||||
ErrFavoritesFixed = errors.New("“我喜欢”不能重命名或删除")
|
||||
ErrPlaylistLimit = errors.New("歌单或歌单里的歌已达上限")
|
||||
)
|
||||
|
||||
type PlaylistRecord struct {
|
||||
ID string
|
||||
Name string
|
||||
Favorites bool
|
||||
TrackCount int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Cover *Cover
|
||||
}
|
||||
|
||||
// 链接曲目的封面在对象存储中,只有对象键。
|
||||
type Cover struct {
|
||||
URL string
|
||||
Key track.ObjectKey
|
||||
}
|
||||
|
||||
type PlaylistItem struct {
|
||||
Track track.Track
|
||||
Meta track.Meta
|
||||
}
|
||||
|
||||
// 所有方法都按 ownerID 过滤:别人的歌单和不存在的歌单一样,返回 ErrPlaylistNotFound。“我喜欢”由 Members.Register 创建。
|
||||
type Playlists interface {
|
||||
// “我喜欢”在最前,其余按创建的先后。Cover 是歌单里排在最前、有封面的那首歌的封面,没有时为 nil。
|
||||
MemberPlaylists(ctx context.Context, ownerID string) ([]PlaylistRecord, error)
|
||||
// 已有 limit 个歌单(含“我喜欢”)时返回 ErrPlaylistLimit。
|
||||
CreatePlaylist(ctx context.Context, ownerID, name string, limit int) (PlaylistRecord, error)
|
||||
// “我喜欢”返回 ErrFavoritesFixed。
|
||||
RenamePlaylist(ctx context.Context, ownerID, id, name string) error
|
||||
// “我喜欢”返回 ErrFavoritesFixed。
|
||||
DeletePlaylist(ctx context.Context, ownerID, id string) error
|
||||
// 新加入的在前。
|
||||
PlaylistItems(ctx context.Context, ownerID, id string) ([]PlaylistItem, error)
|
||||
// 已经在歌单里时什么也不做;已有 limit 首时返回 ErrPlaylistLimit。
|
||||
AddPlaylistItem(ctx context.Context, ownerID, id string, item PlaylistItem, limit int) error
|
||||
// 不在歌单里的歌忽略。
|
||||
RemovePlaylistItems(ctx context.Context, ownerID, id string, tracks []track.Track) error
|
||||
// 只看这个成员自己的歌单;没有命中时返回空切片。
|
||||
TrackPlaylists(ctx context.Context, ownerID string, t track.Track) ([]string, 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,9 +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"
|
||||
)
|
||||
|
||||
// Store 的行为与 postgres 适配器一致,包括它会拒绝的输入。
|
||||
@@ -35,13 +33,11 @@ type Store struct {
|
||||
cookies map[string]string
|
||||
brandName string
|
||||
registrationClosed bool
|
||||
playlists []*storedPlaylist
|
||||
}
|
||||
|
||||
type storedMember struct {
|
||||
member member.Member
|
||||
username string
|
||||
email string
|
||||
passwordHash string
|
||||
}
|
||||
|
||||
@@ -59,7 +55,6 @@ var (
|
||||
_ port.Credentials = (*Store)(nil)
|
||||
_ port.APIKeys = (*Store)(nil)
|
||||
_ port.Site = (*Store)(nil)
|
||||
_ port.Playlists = (*Store)(nil)
|
||||
)
|
||||
|
||||
func New() *Store {
|
||||
@@ -123,20 +118,14 @@ func (s *Store) member(id string) *storedMember {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, email *string) (member.Member, error) {
|
||||
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *string) (member.Member, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.members {
|
||||
stored := &s.members[i]
|
||||
m := &stored.member
|
||||
m := &s.members[i].member
|
||||
if m.ID != id {
|
||||
continue
|
||||
}
|
||||
if email != nil {
|
||||
if err := checkText(*email); err != nil {
|
||||
return member.Member{}, err
|
||||
}
|
||||
}
|
||||
if name != nil {
|
||||
if err := checkText(*name); err != nil {
|
||||
return member.Member{}, err
|
||||
@@ -146,22 +135,19 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, ema
|
||||
if avatarHash != nil {
|
||||
m.AvatarHash = *avatarHash
|
||||
}
|
||||
if email != nil && stored.username != "" {
|
||||
stored.email = *email
|
||||
}
|
||||
return *m, nil
|
||||
}
|
||||
return member.Member{}, port.ErrMemberNotFound
|
||||
}
|
||||
|
||||
func (s *Store) AccountProfile(_ context.Context, memberID string) (port.AccountProfile, error) {
|
||||
func (s *Store) Username(_ context.Context, memberID string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(memberID)
|
||||
if m == nil {
|
||||
return port.AccountProfile{}, port.ErrMemberNotFound
|
||||
return "", port.ErrMemberNotFound
|
||||
}
|
||||
return port.AccountProfile{Username: m.username, Email: m.email}, nil
|
||||
return m.username, nil
|
||||
}
|
||||
|
||||
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
|
||||
@@ -178,8 +164,6 @@ func (s *Store) Register(_ context.Context, memberID, username, passwordHash str
|
||||
return port.ErrUsernameTaken
|
||||
}
|
||||
m.username, m.passwordHash = username, passwordHash
|
||||
now := time.Now()
|
||||
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -188,7 +172,7 @@ func (s *Store) AccountByUsername(_ context.Context, username string) (port.Acco
|
||||
defer s.mu.Unlock()
|
||||
for _, m := range s.members {
|
||||
if username != "" && m.username == username {
|
||||
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash}, nil
|
||||
return port.Account{Member: m.member, Username: m.username, PasswordHash: m.passwordHash}, nil
|
||||
}
|
||||
}
|
||||
return port.Account{}, port.ErrMemberNotFound
|
||||
@@ -222,6 +206,12 @@ func (s *Store) DeleteSession(_ context.Context, tokenHash []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSessions(_ context.Context, memberID string, keepTokenHash []byte) ([][]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.deleteSessions(memberID, keepTokenHash), nil
|
||||
}
|
||||
|
||||
func (s *Store) SetPassword(_ context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -502,225 +492,3 @@ func (s *Store) slugOwner(slug string) *port.RoomRecord {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type storedPlaylist struct {
|
||||
id, owner, name string
|
||||
favorites bool
|
||||
items []port.PlaylistItem
|
||||
created, updated time.Time
|
||||
}
|
||||
|
||||
func (p *storedPlaylist) cover() *port.Cover {
|
||||
for i := len(p.items) - 1; i >= 0; i-- {
|
||||
if m := p.items[i].Meta; m.CoverURL != "" || m.CoverKey != "" {
|
||||
return &port.Cover{URL: m.CoverURL, Key: m.CoverKey}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 调用方持有 s.mu。
|
||||
func (s *Store) playlist(ownerID, id string) *storedPlaylist {
|
||||
for _, p := range s.playlists {
|
||||
if p.id == id && p.owner == ownerID {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MemberPlaylists(_ context.Context, ownerID string) ([]port.PlaylistRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var lists []port.PlaylistRecord
|
||||
for _, p := range s.playlists {
|
||||
if p.owner != ownerID {
|
||||
continue
|
||||
}
|
||||
r := port.PlaylistRecord{
|
||||
ID: p.id,
|
||||
Name: p.name,
|
||||
Favorites: p.favorites,
|
||||
TrackCount: len(p.items),
|
||||
CreatedAt: p.created,
|
||||
UpdatedAt: p.updated,
|
||||
Cover: p.cover(),
|
||||
}
|
||||
if p.favorites {
|
||||
lists = append([]port.PlaylistRecord{r}, lists...)
|
||||
} else {
|
||||
lists = append(lists, r)
|
||||
}
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreatePlaylist(_ context.Context, ownerID, name string, limit int) (port.PlaylistRecord, error) {
|
||||
if err := checkText(name); err != nil {
|
||||
return port.PlaylistRecord{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, p := range s.playlists {
|
||||
if p.owner == ownerID {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n >= limit {
|
||||
return port.PlaylistRecord{}, port.ErrPlaylistLimit
|
||||
}
|
||||
now := time.Now()
|
||||
p := &storedPlaylist{id: s.newID(), owner: ownerID, name: name, created: now, updated: now}
|
||||
s.playlists = append(s.playlists, p)
|
||||
return port.PlaylistRecord{ID: p.id, Name: name, CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RenamePlaylist(_ context.Context, ownerID, id, name string) error {
|
||||
if err := checkText(name); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p := s.playlist(ownerID, id)
|
||||
switch {
|
||||
case p == nil:
|
||||
return port.ErrPlaylistNotFound
|
||||
case p.favorites:
|
||||
return port.ErrFavoritesFixed
|
||||
}
|
||||
p.name = name
|
||||
p.updated = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePlaylist(_ context.Context, ownerID, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p := s.playlist(ownerID, id)
|
||||
switch {
|
||||
case p == nil:
|
||||
return port.ErrPlaylistNotFound
|
||||
case p.favorites:
|
||||
return port.ErrFavoritesFixed
|
||||
}
|
||||
s.playlists = slices.DeleteFunc(s.playlists, func(other *storedPlaylist) bool { return other == p })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) PlaylistItems(_ context.Context, ownerID, id string) ([]port.PlaylistItem, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p := s.playlist(ownerID, id)
|
||||
if p == nil {
|
||||
return nil, port.ErrPlaylistNotFound
|
||||
}
|
||||
items := make([]port.PlaylistItem, 0, len(p.items))
|
||||
for i := len(p.items) - 1; i >= 0; i-- {
|
||||
items = append(items, p.items[i])
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Store) AddPlaylistItem(_ context.Context, ownerID, id string, item port.PlaylistItem, limit int) error {
|
||||
m := item.Meta
|
||||
if err := checkText(item.Track.ID, m.Title, m.Artist, m.Album, m.CoverURL, string(m.CoverKey), m.PageURL); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p := s.playlist(ownerID, id)
|
||||
if p == nil {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
if slices.ContainsFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == item.Track }) {
|
||||
return nil
|
||||
}
|
||||
if len(p.items) >= limit {
|
||||
return port.ErrPlaylistLimit
|
||||
}
|
||||
p.items = append(p.items, item)
|
||||
p.updated = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
if p == nil {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
n := len(p.items)
|
||||
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) TrackPlaylists(_ context.Context, ownerID string, t track.Track) ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ids := []string{}
|
||||
for _, p := range s.playlists {
|
||||
if p.owner == ownerID && slices.ContainsFunc(p.items, func(i port.PlaylistItem) bool { return i.Track == t }) {
|
||||
ids = append(ids, p.id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *Store) CopyPlaylistItems(_ context.Context, ownerID, fromID, toID string, tracks []track.Track, limit int) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
from, to := s.playlist(ownerID, fromID), s.playlist(ownerID, toID)
|
||||
if from == nil || to == nil {
|
||||
return 0, port.ErrPlaylistNotFound
|
||||
}
|
||||
var fresh []port.PlaylistItem
|
||||
for _, t := range tracks {
|
||||
i := slices.IndexFunc(from.items, func(i port.PlaylistItem) bool { return i.Track == t })
|
||||
if i < 0 || slices.ContainsFunc(to.items, func(i port.PlaylistItem) bool { return i.Track == t }) ||
|
||||
slices.ContainsFunc(fresh, func(i port.PlaylistItem) bool { return i.Track == t }) {
|
||||
continue
|
||||
}
|
||||
fresh = append(fresh, from.items[i])
|
||||
}
|
||||
if len(fresh) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if len(to.items)+len(fresh) > limit {
|
||||
return 0, port.ErrPlaylistLimit
|
||||
}
|
||||
slices.Reverse(fresh)
|
||||
to.items = append(to.items, fresh...)
|
||||
to.updated = time.Now()
|
||||
return len(fresh), nil
|
||||
}
|
||||
|
||||
func (s *Store) MovePlaylistItems(_ context.Context, ownerID, id string, tracks []track.Track, before *track.Track) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p := s.playlist(ownerID, id)
|
||||
if p == nil {
|
||||
return port.ErrPlaylistNotFound
|
||||
}
|
||||
order := make([]track.Track, len(p.items))
|
||||
for i, item := range p.items {
|
||||
order[len(order)-1-i] = item.Track
|
||||
}
|
||||
moved, err := playlist.Move(order, tracks, before)
|
||||
if err != nil || slices.Equal(moved, order) {
|
||||
return err
|
||||
}
|
||||
byTrack := make(map[track.Track]port.PlaylistItem, len(p.items))
|
||||
for _, item := range p.items {
|
||||
byTrack[item.Track] = item
|
||||
}
|
||||
for i, t := range moved {
|
||||
p.items[len(moved)-1-i] = byTrack[t]
|
||||
}
|
||||
p.updated = time.Now()
|
||||
return nil
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package porttest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
|
||||
store := New()
|
||||
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := "a\x00@example.com"
|
||||
if _, err := store.UpdateMember(t.Context(), m.ID, nil, nil, &bad); err == nil {
|
||||
t.Error("含 NUL 的邮箱应报错")
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/dihemoe/alisten/internal/app/identity"
|
||||
"github.com/dihemoe/alisten/internal/app/lobby"
|
||||
"github.com/dihemoe/alisten/internal/app/media"
|
||||
"github.com/dihemoe/alisten/internal/app/playlist"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
"github.com/dihemoe/alisten/internal/app/prepare"
|
||||
"github.com/dihemoe/alisten/internal/app/realtime"
|
||||
@@ -78,16 +77,15 @@ func newPublic(cfg serveConfig, pool *pgxpool.Pool, rdb *goredis.Client, instanc
|
||||
registerRuntimeMetrics(metrics, host, gateway)
|
||||
rooms := lobby.New(store, leases)
|
||||
services := api.Services{
|
||||
Identity: identity.New(store, store, store, gateway),
|
||||
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), gateway, base.With("component", "accounts")),
|
||||
Playlists: playlist.New(store, store, store, music, redis.NewAccountQuota(rdb)),
|
||||
Lobby: rooms,
|
||||
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
|
||||
Gateway: gateway,
|
||||
Catalog: music,
|
||||
Media: media.NewLibrary(store, store, objects),
|
||||
Playback: media.NewPlayback(src.playback, store),
|
||||
Site: site.New(store),
|
||||
Identity: identity.New(store, store, store, gateway),
|
||||
Accounts: identity.NewAccounts(store, store, redis.NewAccountQuota(rdb), gateway, base.With("component", "accounts")),
|
||||
Lobby: rooms,
|
||||
Admin: admin.New(cfg.adminToken, rooms, store, store, store, src.neteaseLogin, src.qqLogin, src.bilibiliLogin, store, gateway),
|
||||
Gateway: gateway,
|
||||
Catalog: music,
|
||||
Media: media.NewLibrary(store, store, objects),
|
||||
Playback: media.NewPlayback(src.playback, store),
|
||||
Site: site.New(store),
|
||||
|
||||
StorageURL: cfg.storageURL,
|
||||
TrustedProxies: cfg.trustedProxies,
|
||||
|
||||
+32
-66
@@ -1,4 +1,4 @@
|
||||
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token,自己原来的 token 随即失效;修改密码、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
|
||||
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token,自己原来的 token 随即失效;修改密码、退出其他设备、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
@@ -24,22 +24,6 @@ Authorization: Bearer $alice
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"email": " Alice@Example.COM "}
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
|
||||
|
||||
-- request --
|
||||
POST /api/account
|
||||
Authorization: Bearer $alice
|
||||
@@ -64,31 +48,6 @@ Authorization: Bearer $bob
|
||||
200
|
||||
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me
|
||||
Authorization: Bearer $bob
|
||||
|
||||
{"email": "Alice@Example.COM"}
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $bob
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me
|
||||
Authorization: Bearer $bob
|
||||
|
||||
{"email": ""}
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
POST /api/account
|
||||
Authorization: Bearer $bob
|
||||
@@ -132,30 +91,7 @@ Authorization: Bearer $bob
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $alice2
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": "c160f8cc69a4f0bf2b0362752353d060"}, "account": {"username": "alice", "email": "Alice@Example.COM"}}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me
|
||||
Authorization: Bearer $alice2
|
||||
|
||||
{"email": ""}
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
200
|
||||
{"member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
|
||||
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
@@ -197,6 +133,36 @@ Authorization: Bearer $alice2
|
||||
401
|
||||
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
|
||||
{}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$carol", "member": {"id": "$carolId", "name": "$carolName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
POST /api/sessions
|
||||
Authorization: Bearer $carol
|
||||
|
||||
{"username": "alice", "password": "battery staple"}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice3", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
|
||||
|
||||
-- request --
|
||||
DELETE /api/sessions/others
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
204
|
||||
|
||||
-- request --
|
||||
GET /api/me
|
||||
Authorization: Bearer $alice3
|
||||
-- response --
|
||||
401
|
||||
{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}}
|
||||
|
||||
-- request --
|
||||
DELETE /api/sessions/current
|
||||
Authorization: Bearer $alice
|
||||
|
||||
-353
@@ -1,353 +0,0 @@
|
||||
歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;可以批量移出、复制到别的歌单、调整顺序;别人的歌单和不存在的一样。
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
|
||||
{}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
GET /api/me/playlists
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
403
|
||||
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"name": "通勤"}
|
||||
-- response --
|
||||
403
|
||||
{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}}
|
||||
|
||||
-- request --
|
||||
POST /api/account
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"username": "alice", "password": "correct horse"}
|
||||
-- response --
|
||||
201
|
||||
{"account": {"username": "alice"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me/playlists
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
200
|
||||
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists/$favorites/items
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"track": {"source": "netease", "id": "186016"}}
|
||||
-- response --
|
||||
201
|
||||
{"item": {"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/$favorites/items
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"track": {"source": "netease", "id": "186016"}}
|
||||
-- response --
|
||||
201
|
||||
{"item": {"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/$favorites/items
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"track": {"source": "netease", "id": "185811"}}
|
||||
-- response --
|
||||
201
|
||||
{"item": {"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 --
|
||||
GET /api/me/playlists/$favorites/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/$favorites/items
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"track": {"source": "link", "id": "https://example.com/song.mp3"}}
|
||||
-- response --
|
||||
409
|
||||
{"error": {"code": "CONFLICT", "message": "这首歌还在准备,准备好之后再保存"}}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"name": " 通勤 "}
|
||||
-- response --
|
||||
201
|
||||
{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"name": " "}
|
||||
-- response --
|
||||
400
|
||||
{"error": {"code": "INVALID_ARGUMENT", "message": "name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符"}}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me/playlists/$commute
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"name": "上班路上"}
|
||||
-- response --
|
||||
204
|
||||
|
||||
-- request --
|
||||
PATCH /api/me/playlists/$favorites
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"name": "别的名字"}
|
||||
-- response --
|
||||
409
|
||||
{"error": {"code": "CONFLICT", "message": "“我喜欢”不能重命名或删除"}}
|
||||
|
||||
-- request --
|
||||
DELETE /api/me/playlists/$favorites
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
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": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}]}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlist-memberships
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"track": {"source": "netease", "id": "186016"}}
|
||||
-- response --
|
||||
200
|
||||
{"playlistIds": ["$favorites", "$commute"]}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlist-memberships
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"track": {"source": "netease", "id": "999"}}
|
||||
-- response --
|
||||
200
|
||||
{"playlistIds": []}
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists/$favorites/removals
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"tracks": [{"source": "netease", "id": "186016"}, {"source": "netease", "id": "999"}]}
|
||||
-- response --
|
||||
204
|
||||
|
||||
-- request --
|
||||
POST /api/me/playlists/$favorites/removals
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"tracks": [{"source": "netease", "id": "186016"}, {"source": "netease", "id": "999"}]}
|
||||
-- response --
|
||||
204
|
||||
|
||||
-- request --
|
||||
GET /api/me/playlists/$favorites/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}}]}
|
||||
|
||||
-- request --
|
||||
POST /api/members
|
||||
|
||||
{}
|
||||
-- response --
|
||||
201
|
||||
{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}}
|
||||
|
||||
-- request --
|
||||
POST /api/account
|
||||
Authorization: Bearer $bob
|
||||
|
||||
{"username": "bob", "password": "correct horse"}
|
||||
-- response --
|
||||
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
|
||||
-- response --
|
||||
404
|
||||
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
|
||||
|
||||
-- request --
|
||||
DELETE /api/me/playlists/$commute
|
||||
Authorization: Bearer $bob
|
||||
-- response --
|
||||
404
|
||||
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
|
||||
|
||||
-- request --
|
||||
GET /api/me/playlists/not-a-uuid/items
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
404
|
||||
{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}}
|
||||
|
||||
-- request --
|
||||
DELETE /api/me/playlists/$commute
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
204
|
||||
|
||||
-- request --
|
||||
GET /api/me/playlists
|
||||
Authorization: Bearer $alice
|
||||
-- response --
|
||||
200
|
||||
{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg"}]}
|
||||
@@ -36,15 +36,6 @@ Authorization: Bearer $alice
|
||||
PATCH /api/me
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"email": "alice@example"}
|
||||
-- response --
|
||||
400
|
||||
{"error": {"code": "INVALID_ARGUMENT", "message": "email:格式不正确,应形如 name@example.com"}}
|
||||
|
||||
-- request --
|
||||
PATCH /api/me
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{}
|
||||
-- response --
|
||||
200
|
||||
|
||||
+2
-4
@@ -1,4 +1,4 @@
|
||||
票据属于签发它的会话:账号在两台设备上各自进入同一个房间,一台设备修改密码之后,另一台设备的 WebSocket 以 4401 断开,
|
||||
票据属于签发它的会话:账号在两台设备上各自进入同一个房间,一台设备退出其他设备之后,另一台设备的 WebSocket 以 4401 断开,
|
||||
用它的票据打开的事件流收到 4401 的 close;这台设备的旧票据再也不能用来进房或调用房间接口。发出请求的设备不受影响。
|
||||
|
||||
-- request --
|
||||
@@ -96,10 +96,8 @@ data: {"room": {"term": 1, "seq": 2, "room": {"id": "$room", "name": "临时", "
|
||||
"members": {"sessions": [{"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}], "onlineCount": 2}}}
|
||||
|
||||
-- request --
|
||||
PUT /api/account/password
|
||||
DELETE /api/sessions/others
|
||||
Authorization: Bearer $alice
|
||||
|
||||
{"currentPassword": "correct horse", "newPassword": "battery staple"}
|
||||
-- response --
|
||||
204
|
||||
-- closed b --
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,34 +86,6 @@ func DecodeLoginRequest(body []byte) (LoginRequest, error) {
|
||||
return decodeLoginRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCreateMyPlaylistRequest(body []byte) (CreateMyPlaylistRequest, error) {
|
||||
return decodeCreateMyPlaylistRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeRenameMyPlaylistRequest(body []byte) (RenameMyPlaylistRequest, error) {
|
||||
return decodeRenameMyPlaylistRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeAddMyPlaylistItemRequest(body []byte) (AddMyPlaylistItemRequest, error) {
|
||||
return decodeAddMyPlaylistItemRequest(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 DecodeListMyTrackPlaylistsRequest(body []byte) (ListMyTrackPlaylistsRequest, error) {
|
||||
return decodeListMyTrackPlaylistsRequest(body, "")
|
||||
}
|
||||
|
||||
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
|
||||
return decodeCreateRoomRequest(body, "")
|
||||
}
|
||||
@@ -174,21 +146,6 @@ func DecodeAdminResetAccountPasswordRequest(body []byte) (AdminResetAccountPassw
|
||||
return decodeAdminResetAccountPasswordRequest(body, "")
|
||||
}
|
||||
|
||||
func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) {
|
||||
var v AddMyPlaylistItemRequest
|
||||
m, err := decodeObject(raw, path, "track")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "track"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeAdminCheckBilibiliLoginRequest(raw json.RawMessage, path string) (AdminCheckBilibiliLoginRequest, error) {
|
||||
var v AdminCheckBilibiliLoginRequest
|
||||
m, err := decodeObject(raw, path, "key")
|
||||
@@ -462,54 +419,12 @@ 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)
|
||||
return v, err
|
||||
}
|
||||
|
||||
func decodeCreateMyPlaylistRequest(raw json.RawMessage, path string) (CreateMyPlaylistRequest, error) {
|
||||
var v CreateMyPlaylistRequest
|
||||
m, err := decodeObject(raw, path, "name")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "name"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.Name, field(path, "name"), 200); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeCreateRoomRequest(raw json.RawMessage, path string) (CreateRoomRequest, error) {
|
||||
var v CreateRoomRequest
|
||||
m, err := decodeObject(raw, path, "description", "name", "password")
|
||||
@@ -581,21 +496,6 @@ func decodeHelloRequest(raw json.RawMessage, path string) (HelloRequest, error)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeListMyTrackPlaylistsRequest(raw json.RawMessage, path string) (ListMyTrackPlaylistsRequest, error) {
|
||||
var v ListMyTrackPlaylistsRequest
|
||||
m, err := decodeObject(raw, path, "track")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "track"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Track, err = decodeTrack(m["track"], field(path, "track")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error) {
|
||||
var v LoginRequest
|
||||
m, err := decodeObject(raw, path, "password", "username")
|
||||
@@ -623,34 +523,6 @@ 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")
|
||||
@@ -829,45 +701,6 @@ func decodeRegisterRequest(raw json.RawMessage, path string) (RegisterRequest, e
|
||||
return v, nil
|
||||
}
|
||||
|
||||
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, "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"), 1000); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeRenameMyPlaylistRequest(raw json.RawMessage, path string) (RenameMyPlaylistRequest, error) {
|
||||
var v RenameMyPlaylistRequest
|
||||
m, err := decodeObject(raw, path, "name")
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = requireFields(m, path, "name"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Name, err = decodeString(m["name"], field(path, "name")); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = checkMaxLength(v.Name, field(path, "name"), 200); err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
|
||||
var v RoomSyncRequest
|
||||
_, err := decodeObject(raw, path)
|
||||
|
||||
@@ -88,38 +88,6 @@ 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,7 +3,6 @@ package protocol
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -68,27 +67,3 @@ 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)
|
||||
}
|
||||
}
|
||||
+5
-197
@@ -156,8 +156,7 @@
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"username": {"type": "string"},
|
||||
"email": {"type": "string", "description": "账号设置了邮箱时才有"}
|
||||
"username": {"type": "string"}
|
||||
},
|
||||
"required": ["username"]
|
||||
},
|
||||
@@ -170,30 +169,6 @@
|
||||
},
|
||||
"required": ["member", "username"]
|
||||
},
|
||||
"PlaylistName": {"type": "string", "description": "歌单名:去掉首尾空白后 1 到 50 个字符", "maxLength": 200},
|
||||
"MyPlaylist": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"favorites": {"type": "boolean", "description": "是否是“我喜欢”"},
|
||||
"trackCount": {"type": "integer"},
|
||||
"createdAtMs": {"type": "integer"},
|
||||
"updatedAtMs": {"type": "integer"},
|
||||
"coverUrl": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null"}
|
||||
},
|
||||
"required": ["id", "name", "favorites", "trackCount", "createdAtMs", "updatedAtMs", "coverUrl"]
|
||||
},
|
||||
"MyPlaylistItem": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {"$ref": "#/$defs/Track"},
|
||||
"meta": {"$ref": "#/$defs/TrackMeta"}
|
||||
},
|
||||
"required": ["track", "meta"]
|
||||
},
|
||||
"Members": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@@ -729,7 +704,7 @@
|
||||
},
|
||||
{
|
||||
"name": "updateMe",
|
||||
"description": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除",
|
||||
"description": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像",
|
||||
"method": "PATCH",
|
||||
"path": "/api/me",
|
||||
"auth": "member",
|
||||
@@ -828,180 +803,13 @@
|
||||
"status": 204
|
||||
},
|
||||
{
|
||||
"name": "listMyPlaylists",
|
||||
"description": "我的歌单:“我喜欢”在最前,其余按创建的先后;只有已注册的账号能使用歌单",
|
||||
"method": "GET",
|
||||
"path": "/api/me/playlists",
|
||||
"auth": "member",
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylist"}}
|
||||
},
|
||||
"required": ["items"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "createMyPlaylist",
|
||||
"description": "新建歌单:最多 50 个(含“我喜欢”),可以重名",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists",
|
||||
"auth": "member",
|
||||
"status": 201,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {"$ref": "#/$defs/PlaylistName"}
|
||||
},
|
||||
"required": ["name"]
|
||||
},
|
||||
"response": {"$ref": "#/$defs/MyPlaylist"}
|
||||
},
|
||||
{
|
||||
"name": "renameMyPlaylist",
|
||||
"description": "重命名歌单;“我喜欢”不能重命名",
|
||||
"method": "PATCH",
|
||||
"path": "/api/me/playlists/{id}",
|
||||
"auth": "member",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {"$ref": "#/$defs/PlaylistName"}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deleteMyPlaylist",
|
||||
"description": "删除歌单;“我喜欢”不能删除",
|
||||
"name": "logoutOtherSessions",
|
||||
"description": "退出其他设备:同一成员的其他成员 token 都失效,发出请求的这个不变",
|
||||
"method": "DELETE",
|
||||
"path": "/api/me/playlists/{id}",
|
||||
"path": "/api/sessions/others",
|
||||
"auth": "member",
|
||||
"status": 204
|
||||
},
|
||||
{
|
||||
"name": "listMyPlaylistItems",
|
||||
"description": "歌单里的歌,新加入的在前",
|
||||
"method": "GET",
|
||||
"path": "/api/me/playlists/{id}/items",
|
||||
"auth": "member",
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}}
|
||||
},
|
||||
"required": ["items"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "addMyPlaylistItem",
|
||||
"description": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/items",
|
||||
"auth": "member",
|
||||
"status": 201,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {"$ref": "#/$defs/Track"}
|
||||
},
|
||||
"required": ["track"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"item": {"$ref": "#/$defs/MyPlaylistItem"}
|
||||
},
|
||||
"required": ["item"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "removeMyPlaylistItems",
|
||||
"description": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlists/{id}/removals",
|
||||
"auth": "member",
|
||||
"status": 204,
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 1000}
|
||||
},
|
||||
"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"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "listMyTrackPlaylists",
|
||||
"description": "这首歌在我的哪些歌单里",
|
||||
"method": "POST",
|
||||
"path": "/api/me/playlist-memberships",
|
||||
"auth": "member",
|
||||
"request": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {"$ref": "#/$defs/Track"}
|
||||
},
|
||||
"required": ["track"]
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"playlistIds": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"required": ["playlistIds"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "listRooms",
|
||||
"description": "房间列表",
|
||||
|
||||
+28
-683
@@ -17,10 +17,6 @@
|
||||
"Account": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"email": {
|
||||
"description": "账号设置了邮箱时才有",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -41,30 +37,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AddMyPlaylistItemRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"track"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AddMyPlaylistItemResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"item": {
|
||||
"$ref": "#/components/schemas/MyPlaylistItem"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"item"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AdminAccount": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -441,40 +413,6 @@
|
||||
],
|
||||
"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"
|
||||
@@ -495,20 +433,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreateMyPlaylistRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "歌单名:去掉首尾空白后 1 到 50 个字符",
|
||||
"maxLength": 200,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CreateRoomRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -713,63 +637,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListMyPlaylistItemsResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MyPlaylistItem"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListMyPlaylistsResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MyPlaylist"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListMyTrackPlaylistsRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"track": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"track"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListMyTrackPlaylistsResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"playlistIds": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"playlistIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ListRoomsResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -867,87 +734,6 @@
|
||||
],
|
||||
"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": {
|
||||
"coverUrl": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null"
|
||||
},
|
||||
"createdAtMs": {
|
||||
"type": "integer"
|
||||
},
|
||||
"favorites": {
|
||||
"description": "是否是“我喜欢”",
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"trackCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAtMs": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"favorites",
|
||||
"trackCount",
|
||||
"createdAtMs",
|
||||
"updatedAtMs",
|
||||
"coverUrl"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MyPlaylistItem": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/TrackMeta"
|
||||
},
|
||||
"track": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"track",
|
||||
"meta"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"NowPlaying": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -1259,37 +1045,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RemoveMyPlaylistItemsRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tracks": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Track"
|
||||
},
|
||||
"maxItems": 1000,
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tracks"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RenameMyPlaylistRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "歌单名:去掉首尾空白后 1 到 50 个字符",
|
||||
"maxLength": 200,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RoomInfo": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -1741,7 +1496,7 @@
|
||||
},
|
||||
"info": {
|
||||
"title": "Alisten",
|
||||
"version": "12f9c11ba788ac55"
|
||||
"version": "812b22c0beac413d"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -2875,443 +2630,7 @@
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除"
|
||||
}
|
||||
},
|
||||
"/api/me/playlist-memberships": {
|
||||
"post": {
|
||||
"operationId": "listMyTrackPlaylists",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListMyTrackPlaylistsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListMyTrackPlaylistsResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "这首歌在我的哪些歌单里"
|
||||
}
|
||||
},
|
||||
"/api/me/playlists": {
|
||||
"get": {
|
||||
"operationId": "listMyPlaylists",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListMyPlaylistsResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "我的歌单:“我喜欢”在最前,其余按创建的先后;只有已注册的账号能使用歌单"
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createMyPlaylist",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateMyPlaylistRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MyPlaylist"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "新建歌单:最多 50 个(含“我喜欢”),可以重名"
|
||||
}
|
||||
},
|
||||
"/api/me/playlists/{id}": {
|
||||
"delete": {
|
||||
"operationId": "deleteMyPlaylist",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "删除歌单;“我喜欢”不能删除"
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "renameMyPlaylist",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RenameMyPlaylistRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"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",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListMyPlaylistItemsResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "歌单里的歌,新加入的在前"
|
||||
},
|
||||
"post": {
|
||||
"operationId": "addMyPlaylistItem",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddMyPlaylistItemRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddMyPlaylistItemResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "把一首歌加入歌单:元数据由服务端查询,链接曲目要先准备好;已经在歌单里时不变;一个歌单最多 1000 首"
|
||||
}
|
||||
},
|
||||
"/api/me/playlists/{id}/moves": {
|
||||
"post": {
|
||||
"operationId": "moveMyPlaylistItems",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MoveMyPlaylistItemsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"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 首;不在歌单里的忽略"
|
||||
"summary": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像"
|
||||
}
|
||||
},
|
||||
"/api/members": {
|
||||
@@ -4111,6 +3430,32 @@
|
||||
"summary": "退出登录:发出请求的成员 token 随即失效"
|
||||
}
|
||||
},
|
||||
"/api/sessions/others": {
|
||||
"delete": {
|
||||
"operationId": "logoutOtherSessions",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "成功"
|
||||
},
|
||||
"default": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "失败"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"memberToken": []
|
||||
}
|
||||
],
|
||||
"summary": "退出其他设备:同一成员的其他成员 token 都失效,发出请求的这个不变"
|
||||
}
|
||||
},
|
||||
"/api/site": {
|
||||
"get": {
|
||||
"operationId": "getSite",
|
||||
|
||||
+3
-126
@@ -4,15 +4,14 @@ package protocol
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
const Version = "12f9c11ba788ac55"
|
||||
const Version = "812b22c0beac413d"
|
||||
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Email *string `json:"email,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type ActivityKind string
|
||||
@@ -26,14 +25,6 @@ const (
|
||||
ActivityKindFailed ActivityKind = "failed"
|
||||
)
|
||||
|
||||
type AddMyPlaylistItemRequest struct {
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type AddMyPlaylistItemResponse struct {
|
||||
Item MyPlaylistItem `json:"item"`
|
||||
}
|
||||
|
||||
type AdminAccount struct {
|
||||
Member Member `json:"member"`
|
||||
Username string `json:"username"`
|
||||
@@ -164,23 +155,6 @@ 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 {
|
||||
@@ -188,10 +162,6 @@ type CreateMemberResponse struct {
|
||||
MemberToken string `json:"memberToken"`
|
||||
}
|
||||
|
||||
type CreateMyPlaylistRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type CreateRoomRequest struct {
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
@@ -274,46 +244,6 @@ type HelloResponse struct {
|
||||
ServerTimeMs int64 `json:"serverTimeMs"`
|
||||
}
|
||||
|
||||
type ListMyPlaylistItemsResponse struct {
|
||||
Items []MyPlaylistItem `json:"items"`
|
||||
}
|
||||
|
||||
func (v ListMyPlaylistItemsResponse) MarshalJSON() ([]byte, error) {
|
||||
type plain ListMyPlaylistItemsResponse
|
||||
if v.Items == nil {
|
||||
v.Items = []MyPlaylistItem{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type ListMyPlaylistsResponse struct {
|
||||
Items []MyPlaylist `json:"items"`
|
||||
}
|
||||
|
||||
func (v ListMyPlaylistsResponse) MarshalJSON() ([]byte, error) {
|
||||
type plain ListMyPlaylistsResponse
|
||||
if v.Items == nil {
|
||||
v.Items = []MyPlaylist{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type ListMyTrackPlaylistsRequest struct {
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type ListMyTrackPlaylistsResponse struct {
|
||||
PlaylistIds []string `json:"playlistIds"`
|
||||
}
|
||||
|
||||
func (v ListMyTrackPlaylistsResponse) MarshalJSON() ([]byte, error) {
|
||||
type plain ListMyTrackPlaylistsResponse
|
||||
if v.PlaylistIds == nil {
|
||||
v.PlaylistIds = []string{}
|
||||
}
|
||||
return json.Marshal(plain(v))
|
||||
}
|
||||
|
||||
type ListRoomsResponse struct {
|
||||
Items []RoomListing `json:"items"`
|
||||
}
|
||||
@@ -356,34 +286,6 @@ 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"`
|
||||
Favorites bool `json:"favorites"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TrackCount int64 `json:"trackCount"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type MyPlaylistItem struct {
|
||||
Meta TrackMeta `json:"meta"`
|
||||
Track Track `json:"track"`
|
||||
}
|
||||
|
||||
type NowPlaying struct {
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Entry Entry `json:"entry"`
|
||||
@@ -540,22 +442,6 @@ type RegisterResponse struct {
|
||||
Account Account `json:"account"`
|
||||
}
|
||||
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RoomActivityEvent struct {
|
||||
Entry *Entry `json:"entry,omitempty"`
|
||||
Kind ActivityKind `json:"kind"`
|
||||
@@ -772,16 +658,7 @@ var (
|
||||
RouteChangePassword = Route{Pattern: "PUT /api/account/password", Auth: AuthMember, Status: 204}
|
||||
RouteLogin = Route{Pattern: "POST /api/sessions", Auth: AuthMember, Status: 201}
|
||||
RouteLogout = Route{Pattern: "DELETE /api/sessions/current", Auth: AuthMember, Status: 204}
|
||||
RouteListMyPlaylists = Route{Pattern: "GET /api/me/playlists", Auth: AuthMember, Status: 200}
|
||||
RouteCreateMyPlaylist = Route{Pattern: "POST /api/me/playlists", Auth: AuthMember, Status: 201}
|
||||
RouteRenameMyPlaylist = Route{Pattern: "PATCH /api/me/playlists/{id}", Auth: AuthMember, Status: 204}
|
||||
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}
|
||||
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}
|
||||
RouteListMyTrackPlaylists = Route{Pattern: "POST /api/me/playlist-memberships", Auth: AuthMember, Status: 200}
|
||||
RouteLogoutOtherSessions = Route{Pattern: "DELETE /api/sessions/others", 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}
|
||||
|
||||
@@ -22,7 +22,7 @@ server {
|
||||
|
||||
include /tmp/alisten.d/*.conf;
|
||||
|
||||
location ~ ^/(?:admin|settings|history|playlists(?:/[^/]+)?)?$ {
|
||||
location ~ ^/(?:admin|settings|history)?$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
+20
-41
@@ -1,14 +1,13 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { Page, Panel, RoomPage } from './route'
|
||||
import type { SearchQuery } from '@/features/search'
|
||||
import { ChevronLeft, ChevronRight, Headphones, History, House, Library, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Headphones, History, House, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
|
||||
import { Suspense } from 'react'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { ChatPanel } from '@/features/chat'
|
||||
import { HistoryPage } from '@/features/history'
|
||||
import { ListenPage } from '@/features/lyrics'
|
||||
import { AudioEngine, ManualStartPrompt, PlayerBar, playerStore } from '@/features/playback'
|
||||
import { MyPlaylistPage, PlaylistsPage } from '@/features/playlists'
|
||||
import { QueuePage, voteSkip } from '@/features/queue'
|
||||
import { copyInviteLink, EntryDialog, Lobby, roomEntryStore, RoomGate } from '@/features/rooms'
|
||||
import { HeaderSearch, PlaylistPage, SearchPage, searchStore } from '@/features/search'
|
||||
@@ -30,12 +29,11 @@ const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
|
||||
|
||||
const pageLoading = <div className="page-content"><Skeleton rows={6} /></div>
|
||||
|
||||
const menu: { page: 'lobby' | 'settings' | 'history' | 'playlists' | RoomPage, label: string, icon: LucideIcon }[] = [
|
||||
const menu: { page: 'lobby' | 'settings' | 'history' | RoomPage, label: string, icon: LucideIcon }[] = [
|
||||
{ page: 'listen', label: '正在聆听', icon: Headphones },
|
||||
{ page: 'queue', label: '播放列表', icon: ListMusic },
|
||||
{ page: 'chat', label: '房间聊天', icon: MessageCircle },
|
||||
{ page: 'history', label: '播放历史', icon: History },
|
||||
{ page: 'playlists', label: '我的歌单', icon: Library },
|
||||
{ page: 'lobby', label: '发现房间', icon: House },
|
||||
{ page: 'settings', label: '设置', icon: Settings },
|
||||
]
|
||||
@@ -47,11 +45,9 @@ const mobileMenu: { page: RoomPage, label: string, icon: LucideIcon }[] = [
|
||||
]
|
||||
|
||||
const pageClasses: Partial<Record<Page, string>> = {
|
||||
'queue': 'scrollbar-hidden',
|
||||
'history': 'scrollbar-hidden',
|
||||
'playlists': 'scrollbar-hidden',
|
||||
'my-playlist': 'scrollbar-hidden',
|
||||
'settings': 'settings-view',
|
||||
queue: 'scrollbar-hidden',
|
||||
history: 'scrollbar-hidden',
|
||||
settings: 'settings-view',
|
||||
}
|
||||
|
||||
function closeMenu(element: HTMLElement): void {
|
||||
@@ -116,24 +112,21 @@ function Header({ page }: { page: Page }) {
|
||||
<MoreHorizontal size={20} />
|
||||
</summary>
|
||||
<nav className="main-nav" aria-label="主导航">
|
||||
{menu.map(({ page: target, label, icon: Icon }) => {
|
||||
const selected = page === target || (target === 'playlists' && page === 'my-playlist')
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={target}
|
||||
className={`nav-item text-ui-base ${selected ? 'selected' : ''}`}
|
||||
aria-current={selected ? 'page' : undefined}
|
||||
onClick={(event) => {
|
||||
navigationStore.open(target)
|
||||
closeMenu(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.7} />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{menu.map(({ page: target, label, icon: Icon }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={target}
|
||||
className={`nav-item text-ui-base ${page === target ? 'selected' : ''}`}
|
||||
aria-current={page === target ? 'page' : undefined}
|
||||
onClick={(event) => {
|
||||
navigationStore.open(target)
|
||||
closeMenu(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={1.7} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="nav-item text-ui-base"
|
||||
@@ -194,13 +187,6 @@ function RoutedHistory() {
|
||||
return <HistoryPage filter={route.filter} onFilter={filter => navigationStore.navigate({ ...route, filter })} />
|
||||
}
|
||||
|
||||
function RoutedMyPlaylist() {
|
||||
const route = useStore(navigationStore, s => s.route)
|
||||
if (route.name !== 'my-playlist')
|
||||
return null
|
||||
return <MyPlaylistPage id={route.id} onBack={() => navigationStore.open('playlists')} />
|
||||
}
|
||||
|
||||
function RoutedSearch() {
|
||||
const route = useStore(navigationStore, s => s.route)
|
||||
if (route.name !== 'search')
|
||||
@@ -272,13 +258,6 @@ export function App() {
|
||||
{page === 'playlist' && <RoomGate onBrowse={browse}><RoutedPlaylist /></RoomGate>}
|
||||
{page === 'chat' && <RoomGate onBrowse={browse}><ChatPanel /></RoomGate>}
|
||||
{page === 'history' && <RoutedHistory />}
|
||||
{page === 'playlists' && (
|
||||
<PlaylistsPage
|
||||
onOpen={playlist => navigationStore.navigate({ name: 'my-playlist', id: playlist.id, panel: navigationStore.state.route.panel }, playlist.name)}
|
||||
onRegister={() => navigationStore.open('settings')}
|
||||
/>
|
||||
)}
|
||||
{page === 'my-playlist' && <RoutedMyPlaylist />}
|
||||
{page === 'settings' && <SettingsPage />}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { chatStore } from '@/features/chat'
|
||||
import { historyStore } from '@/features/history'
|
||||
import { lyricsStore } from '@/features/lyrics'
|
||||
import { playerStore, reminderStore, volumeStore } from '@/features/playback'
|
||||
import { playlistsStore } from '@/features/playlists'
|
||||
import { roomEntryStore } from '@/features/rooms'
|
||||
import { searchStore } from '@/features/search'
|
||||
import { appearanceStore, performanceStore, profileStore } from '@/features/settings'
|
||||
@@ -27,7 +26,6 @@ performanceStore.start()
|
||||
volumeStore.start()
|
||||
brandStore.start()
|
||||
profileStore.start()
|
||||
playlistsStore.start()
|
||||
playerStore.start()
|
||||
reminderStore.start()
|
||||
historyStore.start()
|
||||
|
||||
@@ -65,11 +65,11 @@ class NavigationStore extends Store<NavigationState> {
|
||||
}
|
||||
|
||||
// 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。
|
||||
open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void {
|
||||
open(page: 'lobby' | 'admin' | 'settings' | 'history' | RoomPage): void {
|
||||
const { panel } = this.state.route
|
||||
if (page === 'lobby' || page === 'history')
|
||||
this.navigate({ name: page, filter: '', panel })
|
||||
else if (page === 'admin' || page === 'settings' || page === 'playlists')
|
||||
else if (page === 'admin' || page === 'settings')
|
||||
this.navigate({ name: page, panel })
|
||||
else
|
||||
this.#openRoomPage(ref => ({ name: page, ref, panel }))
|
||||
|
||||
@@ -15,9 +15,6 @@ describe('parseRoute', () => {
|
||||
['/settings?panel=queue', { name: 'settings', panel: 'queue' }],
|
||||
['/history', { name: 'history', filter: '', panel: null }],
|
||||
['/history?q=%E6%99%B4&panel=history', { name: 'history', filter: '晴', panel: 'history' }],
|
||||
['/playlists', { name: 'playlists', panel: null }],
|
||||
['/playlists/abc', { name: 'my-playlist', id: 'abc', panel: null }],
|
||||
['/playlists/abc?panel=queue', { name: 'my-playlist', id: 'abc', panel: 'queue' }],
|
||||
['/r/my-room', { name: 'listen', ref: 'my-room', panel: null }],
|
||||
['/r/my-room?panel=history', { name: 'listen', ref: 'my-room', panel: 'history' }],
|
||||
['/r/my-room/queue?panel=queue', { name: 'queue', ref: 'my-room', panel: 'queue' }],
|
||||
@@ -67,11 +64,6 @@ describe('isNewEntry', () => {
|
||||
expect(isNewEntry({ name: 'history', filter: '晴', panel: null }, { name: 'history', filter: '晴天', panel: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('打开另一个自己的歌单写入新记录,同一个歌单只换侧栏时替换当前地址', () => {
|
||||
expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'b', panel: null })).toBe(true)
|
||||
expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'a', panel: 'queue' })).toBe(false)
|
||||
})
|
||||
|
||||
it('提交新的关键词写入新记录,换来源、类别、页码和推荐只替换当前地址', () => {
|
||||
const search = (query: SearchQuery): Route => ({ name: 'search', ref: 'a', panel: null, query })
|
||||
const sunny = search({ view: 'all', keyword: '晴天', source: 'qq', page: 1 })
|
||||
|
||||
@@ -12,8 +12,6 @@ export type Route
|
||||
| { name: 'admin', panel: Panel | null }
|
||||
| { name: 'settings', panel: Panel | null }
|
||||
| { name: 'history', filter: string, panel: Panel | null }
|
||||
| { name: 'playlists', panel: Panel | null }
|
||||
| { name: 'my-playlist', id: string, panel: Panel | null }
|
||||
| { name: RoomPage, ref: string, panel: Panel | null }
|
||||
| { name: 'search', ref: string, panel: Panel | null, query: SearchQuery }
|
||||
| { name: 'playlist', ref: string, panel: Panel | null, source: Source, id: string, page: number }
|
||||
@@ -70,11 +68,6 @@ export function parseRoute({ pathname, search }: Pick<Location, 'pathname' | 'se
|
||||
return { name: 'settings', panel }
|
||||
if (pathname === '/history')
|
||||
return { name: 'history', filter: params.get('q') ?? '', panel }
|
||||
if (pathname === '/playlists')
|
||||
return { name: 'playlists', panel }
|
||||
const own = /^\/playlists\/([^/]+)$/.exec(pathname)
|
||||
if (own)
|
||||
return { name: 'my-playlist', id: own[1], panel }
|
||||
// 不做 decodeURIComponent:UUID、别名和歌单 ID 不含需要转义的字符,而它遇到不合法的转义序列会抛出异常。
|
||||
const room = /^\/r\/([^/]+)(\/.*)?$/.exec(pathname)
|
||||
if (!room)
|
||||
@@ -104,10 +97,7 @@ function pathOf(route: Route): string {
|
||||
case 'admin':
|
||||
case 'settings':
|
||||
case 'history':
|
||||
case 'playlists':
|
||||
return `/${route.name}`
|
||||
case 'my-playlist':
|
||||
return `/playlists/${route.id}`
|
||||
case 'listen':
|
||||
return `/r/${route.ref}`
|
||||
case 'playlist':
|
||||
@@ -136,8 +126,6 @@ export function formatRoute(route: Route): string {
|
||||
export function isNewEntry(from: Route, to: Route): boolean {
|
||||
if (from.name !== to.name || ('ref' in from && 'ref' in to && from.ref !== to.ref))
|
||||
return true
|
||||
if (from.name === 'my-playlist' && to.name === 'my-playlist')
|
||||
return from.id !== to.id
|
||||
if (from.name === 'search' && to.name === 'search')
|
||||
return to.query.view !== 'recommend' && to.query.keyword !== (from.query.view === 'recommend' ? '' : from.query.keyword)
|
||||
if (from.name === 'playlist' && to.name === 'playlist')
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
import type { APIKey, Account, AdminAccount, ErrorInfo, Member, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen'
|
||||
|
||||
export interface AddMyPlaylistItemRequest {
|
||||
track: Track
|
||||
}
|
||||
|
||||
export interface AddMyPlaylistItemResponse {
|
||||
item: MyPlaylistItem
|
||||
}
|
||||
import type { APIKey, Account, AdminAccount, ErrorInfo, Member, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia } from '../realtime/protocol.gen'
|
||||
|
||||
export interface AdminCheckBilibiliLoginRequest {
|
||||
key: string
|
||||
@@ -109,15 +101,6 @@ 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 {
|
||||
@@ -125,10 +108,6 @@ export interface CreateMemberResponse {
|
||||
memberToken: string
|
||||
}
|
||||
|
||||
export interface CreateMyPlaylistRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CreateRoomRequest {
|
||||
description: string
|
||||
name: string
|
||||
@@ -157,22 +136,6 @@ export interface GetSiteResponse {
|
||||
registrationOpen: boolean
|
||||
}
|
||||
|
||||
export interface ListMyPlaylistItemsResponse {
|
||||
items: MyPlaylistItem[]
|
||||
}
|
||||
|
||||
export interface ListMyPlaylistsResponse {
|
||||
items: MyPlaylist[]
|
||||
}
|
||||
|
||||
export interface ListMyTrackPlaylistsRequest {
|
||||
track: Track
|
||||
}
|
||||
|
||||
export interface ListMyTrackPlaylistsResponse {
|
||||
playlistIds: string[]
|
||||
}
|
||||
|
||||
export interface ListRoomsResponse {
|
||||
items: RoomListing[]
|
||||
}
|
||||
@@ -188,11 +151,6 @@ export interface LoginResponse {
|
||||
memberToken: string
|
||||
}
|
||||
|
||||
export interface MoveMyPlaylistItemsRequest {
|
||||
before?: Track
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
password: string
|
||||
username: string
|
||||
@@ -202,14 +160,6 @@ export interface RegisterResponse {
|
||||
account: Account
|
||||
}
|
||||
|
||||
export interface RemoveMyPlaylistItemsRequest {
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export interface RenameMyPlaylistRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface UpdateMeRequest {
|
||||
email?: string
|
||||
name?: string
|
||||
@@ -243,16 +193,7 @@ export function httpClient(send: HttpSend) {
|
||||
changePassword: (body: ChangePasswordRequest) => send<void>({ method: 'PUT', path: '/api/account/password', auth: 'member', body }),
|
||||
login: (body: LoginRequest) => send<LoginResponse>({ method: 'POST', path: '/api/sessions', auth: 'member', body }),
|
||||
logout: () => send<void>({ method: 'DELETE', path: '/api/sessions/current', auth: 'member' }),
|
||||
listMyPlaylists: () => send<ListMyPlaylistsResponse>({ method: 'GET', path: '/api/me/playlists', auth: 'member' }),
|
||||
createMyPlaylist: (body: CreateMyPlaylistRequest) => send<MyPlaylist>({ method: 'POST', path: '/api/me/playlists', auth: 'member', body }),
|
||||
renameMyPlaylist: (id: string, body: RenameMyPlaylistRequest) => send<void>({ method: 'PATCH', path: `/api/me/playlists/${encodeURIComponent(id)}`, auth: 'member', body }),
|
||||
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 }),
|
||||
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 }),
|
||||
listMyTrackPlaylists: (body: ListMyTrackPlaylistsRequest) => send<ListMyTrackPlaylistsResponse>({ method: 'POST', path: '/api/me/playlist-memberships', auth: 'member', body }),
|
||||
logoutOtherSessions: () => send<void>({ method: 'DELETE', path: '/api/sessions/others', auth: 'member' }),
|
||||
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,13 +1,12 @@
|
||||
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
|
||||
|
||||
export const PROTOCOL_VERSION = '12f9c11ba788ac55'
|
||||
export const PROTOCOL_VERSION = '812b22c0beac413d'
|
||||
|
||||
export interface APIKey {
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
email?: string
|
||||
username: string
|
||||
}
|
||||
|
||||
@@ -72,21 +71,6 @@ export interface Members {
|
||||
sessions: Member[]
|
||||
}
|
||||
|
||||
export interface MyPlaylist {
|
||||
coverUrl: string | null
|
||||
createdAtMs: number
|
||||
favorites: boolean
|
||||
id: string
|
||||
name: string
|
||||
trackCount: number
|
||||
updatedAtMs: number
|
||||
}
|
||||
|
||||
export interface MyPlaylistItem {
|
||||
meta: TrackMeta
|
||||
track: Track
|
||||
}
|
||||
|
||||
export interface NowPlaying {
|
||||
durationMs: number
|
||||
entry: Entry
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { PlayRecord } from './records'
|
||||
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { roomStore, sameTrack } from '@/core/room'
|
||||
import { SaveToPlaylistButton } from '@/features/playlists'
|
||||
import { enqueue, sourceName } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { formatClock } from '@/shared/lib/time'
|
||||
@@ -47,7 +46,6 @@ function HistoryRow({ record, compact, connected, playing }: { record: PlayRecor
|
||||
await enqueue(record.track)
|
||||
}}
|
||||
/>
|
||||
<SaveToPlaylistButton track={record.track} title={title} className="song-action song-action-secondary" />
|
||||
<IconButton
|
||||
icon={Trash2}
|
||||
className="song-action song-action-secondary"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ListMusic, MessageCircle, SkipForward, Volume2, VolumeX } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { SaveToPlaylistButton } from '@/features/playlists'
|
||||
import { sourceName } from '@/features/queue'
|
||||
import { formatDuration } from '@/shared/lib/time'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
@@ -91,7 +90,6 @@ export function PlayerBar({ onSkip, onLyrics, onQueue, onChat, queueOpen, chatOp
|
||||
<AsyncIconButton icon={SkipForward} label="投票切歌" disabled={!nowPlaying || !connected} onAction={onSkip} />
|
||||
</div>
|
||||
<div className="player-tools">
|
||||
{nowPlaying && <SaveToPlaylistButton track={nowPlaying.entry.track} title={nowPlaying.entry.meta.title} />}
|
||||
<IconButton icon={muted ? VolumeX : Volume2} label={muted ? '取消静音' : '静音'} onClick={() => volumeStore.setMuted(!muted)} />
|
||||
<div className="volume-control">
|
||||
<input
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { useState } from 'react'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
|
||||
export function CreatePlaylistForm({ onCreated, onCancel }: { onCreated: (playlist: MyPlaylist) => void, onCancel: () => void }) {
|
||||
const [name, setName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
return (
|
||||
<form
|
||||
className="form-stack"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Escape' || event.nativeEvent.isComposing)
|
||||
return
|
||||
// 拦下对话框的 Esc:在“保存到歌单”里,Esc 只回到列表。
|
||||
event.preventDefault()
|
||||
onCancel()
|
||||
}}
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (saving)
|
||||
return
|
||||
setSaving(true)
|
||||
const playlist = await playlistsStore.create(name)
|
||||
if (playlist)
|
||||
onCreated(playlist)
|
||||
else
|
||||
setSaving(false)
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
歌单名
|
||||
{/* 刚打开的 Modal 按 data-autofocus 聚焦;在已经打开的对话框里切换过来时,由 autoFocus 聚焦。 */}
|
||||
<input data-autofocus autoFocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={onCancel}>取消</button>
|
||||
<button type="submit" className="button-primary" disabled={saving || name.trim() === ''}>创建</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { lazyPage } from '@/shared/ui'
|
||||
|
||||
export { playlistsStore } from './playlists-store'
|
||||
export { saveToPlaylistLabel } from './save-label'
|
||||
export { SaveToPlaylistButton, SaveToPlaylistDialog } from './save-to-playlist'
|
||||
export const PlaylistsPage = lazyPage(() => import('./playlists-page').then(module => module.PlaylistsPage))
|
||||
export const MyPlaylistPage = lazyPage(() => import('./my-playlist-page').then(module => module.MyPlaylistPage))
|
||||
@@ -1,273 +0,0 @@
|
||||
import type { MovePlan } from './track-order'
|
||||
import type { MyPlaylist, MyPlaylistItem, Track } from '@/core/realtime'
|
||||
import type { SongAction } from '@/shared/ui'
|
||||
import { ArrowLeft, CirclePlus, ListMusic, ListPlus, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { enqueue } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, Modal, Skeleton } from '@/shared/ui'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
import { PlaylistMenu } from './playlist-menu'
|
||||
import { PlaylistTracks } from './playlist-tracks'
|
||||
import { playlistsStore, trackKey } from './playlists-store'
|
||||
import { saveToPlaylistLabel } from './save-label'
|
||||
import { SaveToPlaylistDialog } from './save-to-playlist'
|
||||
import { SelectionBar } from './selection-bar'
|
||||
import { dropAt } from './track-order'
|
||||
import { noSelection, retain, selectAll } from './track-selection'
|
||||
import './playlists.css'
|
||||
|
||||
function keysOf(items: readonly MyPlaylistItem[]): string[] {
|
||||
return items.map(item => trackKey(item.track))
|
||||
}
|
||||
|
||||
export function MyPlaylistPage({ id, onBack }: { id: string, onBack: () => void }) {
|
||||
const playlist = useStore(playlistsStore, s => s.playlists?.find(p => p.id === id) ?? null)
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
const ephemeral = useStore(roomStore, s => s.room?.room.kind === 'ephemeral')
|
||||
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
|
||||
const [items, setItems] = useState<MyPlaylistItem[] | null>(null)
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [selection, setSelection] = useState(noSelection)
|
||||
// 点了“选择”:没有选中项时也显示勾选框。有选中项时总是处于多选模式。
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
|
||||
const [savingSelected, setSavingSelected] = useState(false)
|
||||
const [confirmingRemoval, setConfirmingRemoval] = useState(false)
|
||||
// 移动按发起的先后逐个请求:每次的 before 都基于上一次移动之后的顺序。一次失败之后,排在它后面的不再发出。
|
||||
const movesRef = useRef({ tail: Promise.resolve(), generation: 0 })
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api.listMyPlaylistItems(id).then(({ items }) => {
|
||||
if (!active)
|
||||
return
|
||||
setItems(items)
|
||||
setSelection(selection => retain(selection, keysOf(items)))
|
||||
}, (error: unknown) => {
|
||||
if (active)
|
||||
setFailed(errorMessage(error))
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [id, attempt, playlist?.trackCount])
|
||||
useEffect(() => {
|
||||
const refresh = () => setAttempt(n => n + 1)
|
||||
window.addEventListener('focus', refresh)
|
||||
return () => window.removeEventListener('focus', refresh)
|
||||
}, [])
|
||||
const selecting = picking || selection.keys.size > 0
|
||||
// 对话框和菜单自己处理 Esc。
|
||||
useEffect(() => {
|
||||
if (!selecting)
|
||||
return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || (event.target instanceof Element && event.target.closest('dialog, [popover]')))
|
||||
return
|
||||
setSelection(noSelection)
|
||||
setPicking(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [selecting])
|
||||
const [now] = useState(Date.now)
|
||||
const name = playlist?.name ?? '歌单'
|
||||
|
||||
const exitSelection = () => {
|
||||
setSelection(noSelection)
|
||||
setPicking(false)
|
||||
}
|
||||
|
||||
// 先在本地排好再请求;失败时回到原来的顺序,并重新读取:歌单可能在别处变过。
|
||||
const move = (target: MyPlaylist, current: MyPlaylistItem[], plan: MovePlan<string>) => {
|
||||
const byKey = new Map(current.map(item => [trackKey(item.track), item]))
|
||||
const trackOf = (key: string) => byKey.get(key)!.track
|
||||
const tracks = plan.moved.map(trackOf)
|
||||
const before = plan.before === undefined ? undefined : trackOf(plan.before)
|
||||
setItems(plan.order.map(key => byKey.get(key)!))
|
||||
const queue = movesRef.current
|
||||
const generation = queue.generation
|
||||
queue.tail = queue.tail.then(async () => {
|
||||
if (queue.generation !== generation || await playlistsStore.moveItems(target, tracks, before))
|
||||
return
|
||||
queue.generation++
|
||||
setItems(current)
|
||||
setAttempt(n => n + 1)
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (target: MyPlaylist, current: MyPlaylistItem[], tracks: Track[]) => {
|
||||
const gone = new Set(tracks.map(trackKey))
|
||||
const rest = current.filter(item => !gone.has(trackKey(item.track)))
|
||||
const previousSelection = selection
|
||||
setItems(rest)
|
||||
setSelection(retain(selection, keysOf(rest)))
|
||||
if (await playlistsStore.removeItems(target, tracks))
|
||||
return
|
||||
setItems(current)
|
||||
setSelection(previousSelection)
|
||||
}
|
||||
|
||||
let body
|
||||
if (failed && items === null) {
|
||||
body = (
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title={failed}
|
||||
action={(
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary"
|
||||
onClick={() => {
|
||||
setFailed(null)
|
||||
setAttempt(n => n + 1)
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else if (items === null) {
|
||||
body = <Skeleton rows={6} />
|
||||
}
|
||||
else if (items.length === 0) {
|
||||
body = (
|
||||
<EmptyState icon={ListMusic} title="这个歌单还是空的">
|
||||
在播放栏、播放列表和播放历史里,可以把歌保存到歌单。
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
else {
|
||||
const actions = ({ track, meta }: MyPlaylistItem): SongAction[] => {
|
||||
const blocked = track.source === 'link' && ephemeral
|
||||
return [
|
||||
{
|
||||
icon: CirclePlus,
|
||||
label: blocked ? '临时房间不能点链接里的歌' : `点歌${bookTitle(meta.title)}`,
|
||||
disabled: !connected || blocked,
|
||||
run: async () => {
|
||||
await enqueue(track)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: ListPlus,
|
||||
label: saveToPlaylistLabel(meta.title),
|
||||
secondary: true,
|
||||
run: async () => setSaving({ track, title: meta.title }),
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: `把${bookTitle(meta.title)}移出这个歌单`,
|
||||
disabled: !playlist,
|
||||
secondary: true,
|
||||
run: async () => {
|
||||
if (playlist)
|
||||
await remove(playlist, items, [track])
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
body = (
|
||||
<PlaylistTracks
|
||||
items={items}
|
||||
playing={playing}
|
||||
selection={selection}
|
||||
selecting={selecting}
|
||||
actions={actions}
|
||||
onSelect={setSelection}
|
||||
onMove={(plan) => {
|
||||
if (playlist)
|
||||
move(playlist, items, plan)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const keys = items ? keysOf(items) : []
|
||||
const selected = items?.filter(item => selection.keys.has(trackKey(item.track))).map(item => item.track) ?? []
|
||||
return (
|
||||
<section className="page-content my-playlist-page" aria-labelledby="my-playlist-title">
|
||||
<button type="button" className="button-ghost my-playlist-back text-ui-sm" aria-label="返回我的歌单" onClick={onBack}>
|
||||
<ArrowLeft size={16} strokeWidth={1.7} aria-hidden="true" />
|
||||
我的歌单
|
||||
</button>
|
||||
<header className="my-playlist-header">
|
||||
<PlaylistCover cover={playlist?.coverUrl ?? null} favorites={playlist?.favorites ?? false} size="lg" />
|
||||
<div className="my-playlist-info">
|
||||
<h1 id="my-playlist-title" className="my-playlist-name">
|
||||
<span>{name}</span>
|
||||
{playlist?.favorites && <FavoritesMark size={22} />}
|
||||
</h1>
|
||||
{playlist && (
|
||||
<p className="text-ui-base muted">
|
||||
{`${playlist.trackCount} 首,更新于${formatPlaylistDate(playlist.updatedAtMs, now)},创建于${formatPlaylistDate(playlist.createdAtMs, now)}`}
|
||||
</p>
|
||||
)}
|
||||
<div className="my-playlist-actions">
|
||||
{items && items.length > 0 && (
|
||||
<button type="button" className="button-secondary my-playlist-select" onClick={() => selecting ? exitSelection() : setPicking(true)}>
|
||||
{selecting ? '完成' : '选择'}
|
||||
</button>
|
||||
)}
|
||||
{playlist && !playlist.favorites && <PlaylistMenu playlist={playlist} align="start" className="my-playlist-menu" onDeleted={onBack} />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{body}
|
||||
{!connected && items?.length ? <p className="text-ui-caption muted my-playlist-hint">进入房间之后才能点歌。</p> : null}
|
||||
{playlist && items && selected.length > 0 && (
|
||||
<SelectionBar
|
||||
count={selected.length}
|
||||
allSelected={selected.length === items.length}
|
||||
onSave={() => setSavingSelected(true)}
|
||||
onTop={() => {
|
||||
const plan = dropAt(keys, selection.keys, 0)
|
||||
if (plan)
|
||||
move(playlist, items, plan)
|
||||
}}
|
||||
onBottom={() => {
|
||||
const plan = dropAt(keys, selection.keys, keys.length)
|
||||
if (plan)
|
||||
move(playlist, items, plan)
|
||||
}}
|
||||
onRemove={() => {
|
||||
if (selected.length > 1)
|
||||
setConfirmingRemoval(true)
|
||||
else
|
||||
void remove(playlist, items, selected)
|
||||
}}
|
||||
onToggleAll={() => setSelection(selected.length === items.length ? noSelection : selectAll(keys))}
|
||||
onClose={exitSelection}
|
||||
/>
|
||||
)}
|
||||
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
|
||||
{playlist && savingSelected && <SaveToPlaylistDialog from={playlist} tracks={selected} onClose={() => setSavingSelected(false)} />}
|
||||
{playlist && items && confirmingRemoval && (
|
||||
<Modal title="移出歌曲" onClose={() => setConfirmingRemoval(false)}>
|
||||
<p className="text-ui-base muted">{`把 ${selected.length} 首歌移出这个歌单?`}</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => setConfirmingRemoval(false)}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-destructive"
|
||||
onClick={() => {
|
||||
setConfirmingRemoval(false)
|
||||
void remove(playlist, items, selected)
|
||||
}}
|
||||
>
|
||||
移出
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Music2, Star } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function FavoritesMark({ size = 14 }: { size?: number }) {
|
||||
return <Star className="playlist-favorites-mark" size={size} fill="currentColor" strokeWidth={1.5} aria-hidden="true" />
|
||||
}
|
||||
|
||||
function FilledStar() {
|
||||
return <Star fill="currentColor" strokeWidth={1.5} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function PlaylistCover({ cover, favorites, size }: { cover: string | null, favorites: boolean, size: 'sm' | 'md' | 'lg' }) {
|
||||
const [failedSrc, setFailedSrc] = useState<string>()
|
||||
const large = size !== 'sm'
|
||||
if (!cover || cover === failedSrc) {
|
||||
return (
|
||||
<span className={`playlist-cover playlist-cover-${size}`} data-favorites={favorites || undefined}>
|
||||
{favorites ? <FilledStar /> : <Music2 strokeWidth={1.5} aria-hidden="true" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={`playlist-cover playlist-cover-${size}`}>
|
||||
{large && <img className="playlist-cover-glow" src={cover} alt="" loading="lazy" decoding="async" />}
|
||||
<img src={cover} alt="" loading="lazy" decoding="async" onError={() => setFailedSrc(cover)} />
|
||||
{large && favorites && <span className="playlist-cover-badge"><FilledStar /></span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
|
||||
// 用本地时间构造时刻:断言与运行测试的时区无关。
|
||||
function at(year: number, month: number, day: number, hour = 0, minute = 0): number {
|
||||
return new Date(year, month - 1, day, hour, minute).getTime()
|
||||
}
|
||||
|
||||
const now = at(2026, 11, 9, 15, 30)
|
||||
|
||||
describe('formatPlaylistDate', () => {
|
||||
it('今天的显示时刻', () => {
|
||||
expect(formatPlaylistDate(at(2026, 11, 9, 14, 20), now)).toBe('今天 14:20')
|
||||
expect(formatPlaylistDate(at(2026, 11, 9, 0, 5), now)).toBe('今天 00:05')
|
||||
})
|
||||
|
||||
it('昨天的显示时刻', () => {
|
||||
expect(formatPlaylistDate(at(2026, 11, 8, 21, 4), now)).toBe('昨天 21:04')
|
||||
expect(formatPlaylistDate(at(2026, 11, 8, 0, 0), at(2026, 11, 9, 23, 59))).toBe('昨天 00:00')
|
||||
})
|
||||
|
||||
it('跨月的昨天', () => {
|
||||
expect(formatPlaylistDate(at(2026, 10, 31, 23, 59), at(2026, 11, 1, 0, 1))).toBe('昨天 23:59')
|
||||
})
|
||||
|
||||
it('同一年更早的只显示月日', () => {
|
||||
expect(formatPlaylistDate(at(2026, 11, 7, 9, 0), now)).toBe('11月7日')
|
||||
expect(formatPlaylistDate(at(2026, 1, 1, 0, 0), now)).toBe('1月1日')
|
||||
})
|
||||
|
||||
it('跨年显示年份', () => {
|
||||
expect(formatPlaylistDate(at(2025, 12, 31, 22, 0), now)).toBe('2025年12月31日')
|
||||
expect(formatPlaylistDate(at(2025, 12, 31, 22, 0), at(2026, 1, 1, 8, 0))).toBe('昨天 22:00')
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
function dayStart(date: Date): number {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
export function formatPlaylistDate(ms: number, now: number): string {
|
||||
const date = new Date(ms)
|
||||
const today = new Date(now)
|
||||
// 夏令时切换的那天不是 24 小时,按天取整。
|
||||
const days = Math.round((dayStart(today) - dayStart(date)) / dayMs)
|
||||
if (days === 0 || days === 1)
|
||||
return `${days === 0 ? '今天' : '昨天'} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
const day = `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
return date.getFullYear() === today.getFullYear() ? day : `${date.getFullYear()}年${day}`
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { KeyboardEvent, ToggleEvent } from 'react'
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { Ellipsis, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { Modal } from '@/shared/ui'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
|
||||
const menuGap = 4
|
||||
const viewportMargin = 8
|
||||
|
||||
const menuKeys: Record<string, (index: number, count: number) => number> = {
|
||||
ArrowDown: (index, count) => (index + 1) % count,
|
||||
ArrowUp: (index, count) => (index - 1 + count) % count,
|
||||
Home: () => 0,
|
||||
End: (_, count) => count - 1,
|
||||
}
|
||||
|
||||
export function PlaylistMenu({ playlist, align = 'end', className = '', onDeleted }: {
|
||||
playlist: MyPlaylist
|
||||
align?: 'start' | 'end'
|
||||
className?: string
|
||||
onDeleted?: () => void
|
||||
}) {
|
||||
const menuId = useId()
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [dialog, setDialog] = useState<'rename' | 'delete' | null>(null)
|
||||
|
||||
// 菜单在顶层按视口定位,不跟随页面滚动,所以滚动和缩放窗口时收起。
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
const close = () => menuRef.current?.hidePopover()
|
||||
document.addEventListener('scroll', close, true)
|
||||
window.addEventListener('resize', close)
|
||||
return () => {
|
||||
document.removeEventListener('scroll', close, true)
|
||||
window.removeEventListener('resize', close)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const items = () => [...menuRef.current!.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
|
||||
// 显示之前量不到菜单的尺寸:先贴着按钮下方对齐,显示之后超出视口再翻到按钮上方、收进视口。
|
||||
const place = (event: ToggleEvent<HTMLDivElement>) => {
|
||||
if (event.newState !== 'open')
|
||||
return
|
||||
const button = buttonRef.current!.getBoundingClientRect()
|
||||
const { style } = event.currentTarget
|
||||
style.top = `${button.bottom + menuGap}px`
|
||||
style.left = align === 'start' ? `${button.left}px` : 'auto'
|
||||
style.right = align === 'end' ? `${document.documentElement.clientWidth - button.right}px` : 'auto'
|
||||
}
|
||||
|
||||
const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
|
||||
const opened = event.newState === 'open'
|
||||
setOpen(opened)
|
||||
if (!opened)
|
||||
return
|
||||
const menu = event.currentTarget
|
||||
const button = buttonRef.current!.getBoundingClientRect()
|
||||
const rect = menu.getBoundingClientRect()
|
||||
if (rect.bottom > window.innerHeight - viewportMargin)
|
||||
menu.style.top = `${Math.max(viewportMargin, button.top - menuGap - rect.height)}px`
|
||||
const left = Math.max(viewportMargin, Math.min(rect.left, document.documentElement.clientWidth - viewportMargin - rect.width))
|
||||
if (left !== rect.left) {
|
||||
menu.style.left = `${left}px`
|
||||
menu.style.right = 'auto'
|
||||
}
|
||||
items()[0]?.focus()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Tab') {
|
||||
event.currentTarget.hidePopover()
|
||||
return
|
||||
}
|
||||
const move = menuKeys[event.key]
|
||||
if (!move)
|
||||
return
|
||||
event.preventDefault()
|
||||
const list = items()
|
||||
list[move(list.indexOf(document.activeElement as HTMLElement), list.length)]?.focus()
|
||||
}
|
||||
|
||||
const choose = (next: 'rename' | 'delete') => {
|
||||
menuRef.current!.hidePopover()
|
||||
setDialog(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={`icon-button playlist-menu-button ${className}`}
|
||||
popoverTarget={menuId}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`“${playlist.name}”的更多操作`}
|
||||
title="更多操作"
|
||||
>
|
||||
<Ellipsis size={20} strokeWidth={1.7} aria-hidden="true" />
|
||||
</button>
|
||||
<div ref={menuRef} id={menuId} className="playlist-menu" popover="auto" role="menu" aria-label={`“${playlist.name}”的操作`} onBeforeToggle={place} onToggle={onToggle} onKeyDown={onKeyDown}>
|
||||
<button type="button" role="menuitem" tabIndex={-1} className="playlist-menu-item" onClick={() => choose('rename')}>
|
||||
<Pencil size={16} strokeWidth={1.7} aria-hidden="true" />
|
||||
重命名
|
||||
</button>
|
||||
<button type="button" role="menuitem" tabIndex={-1} className="playlist-menu-item playlist-menu-danger" onClick={() => choose('delete')}>
|
||||
<Trash2 size={16} strokeWidth={1.7} aria-hidden="true" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
{dialog === 'rename' && <RenameDialog playlist={playlist} onClose={() => setDialog(null)} />}
|
||||
{dialog === 'delete' && <DeleteDialog playlist={playlist} onClose={() => setDialog(null)} onDeleted={onDeleted} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RenameDialog({ playlist, onClose }: { playlist: MyPlaylist, onClose: () => void }) {
|
||||
const [name, setName] = useState(playlist.name)
|
||||
const [saving, setSaving] = useState(false)
|
||||
return (
|
||||
<Modal title="重命名" onClose={onClose}>
|
||||
<form
|
||||
className="form-stack"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (saving)
|
||||
return
|
||||
setSaving(true)
|
||||
if (await playlistsStore.rename(playlist, name))
|
||||
onClose()
|
||||
else
|
||||
setSaving(false)
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
歌单名
|
||||
<input data-autofocus required maxLength={50} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<button type="submit" className="button-primary" disabled={saving || name.trim() === ''}>保存</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteDialog({ playlist, onClose, onDeleted }: { playlist: MyPlaylist, onClose: () => void, onDeleted?: () => void }) {
|
||||
const [removing, setRemoving] = useState(false)
|
||||
return (
|
||||
<Modal title="删除歌单" onClose={onClose}>
|
||||
<p className="text-ui-base muted">{`删除“${playlist.name}”之后,里面的歌也一起删除。`}</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={onClose}>取消</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button-destructive"
|
||||
disabled={removing}
|
||||
onClick={async () => {
|
||||
setRemoving(true)
|
||||
if (await playlistsStore.remove(playlist)) {
|
||||
onClose()
|
||||
onDeleted?.()
|
||||
}
|
||||
else {
|
||||
setRemoving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { PlaylistSort } from './playlist-sort'
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sortPlaylists } from './playlist-sort'
|
||||
|
||||
function playlist(id: string, name: string, trackCount: number, createdAtMs: number, updatedAtMs: number, favorites = false): MyPlaylist {
|
||||
return { id, name, favorites, trackCount, createdAtMs, updatedAtMs, coverUrl: null }
|
||||
}
|
||||
|
||||
const favorites = playlist('p0', '我喜欢', 3, 50, 50, true)
|
||||
const commute = playlist('p1', '通勤', 36, 100, 400)
|
||||
const chill = playlist('p2', 'Chill', 5, 200, 300)
|
||||
const running = playlist('p3', '晨跑', 12, 300, 200)
|
||||
const night = playlist('p4', '安静的夜晚', 0, 400, 500)
|
||||
const all = [commute, favorites, chill, running, night]
|
||||
|
||||
function names(sort: PlaylistSort, list: MyPlaylist[] = all): string[] {
|
||||
return sortPlaylists(list, sort).map(p => p.name)
|
||||
}
|
||||
|
||||
describe('sortPlaylists', () => {
|
||||
it('按名称排序,中文按拼音', () => {
|
||||
expect(names({ key: 'name', order: 'asc' })).toEqual(['我喜欢', '安静的夜晚', '晨跑', '通勤', 'Chill'])
|
||||
expect(names({ key: 'name', order: 'desc' })).toEqual(['我喜欢', 'Chill', '通勤', '晨跑', '安静的夜晚'])
|
||||
})
|
||||
|
||||
it('名称里的数字按大小排', () => {
|
||||
const list = [playlist('a', '歌单 10', 0, 1, 1), playlist('b', '歌单 2', 0, 2, 2)]
|
||||
expect(names({ key: 'name', order: 'asc' }, list)).toEqual(['歌单 2', '歌单 10'])
|
||||
})
|
||||
|
||||
it('按创建时间排序', () => {
|
||||
expect(names({ key: 'createdAt', order: 'desc' })).toEqual(['我喜欢', '安静的夜晚', '晨跑', 'Chill', '通勤'])
|
||||
expect(names({ key: 'createdAt', order: 'asc' })).toEqual(['我喜欢', '通勤', 'Chill', '晨跑', '安静的夜晚'])
|
||||
})
|
||||
|
||||
it('按更新时间排序', () => {
|
||||
expect(names({ key: 'updatedAt', order: 'desc' })).toEqual(['我喜欢', '安静的夜晚', '通勤', 'Chill', '晨跑'])
|
||||
expect(names({ key: 'updatedAt', order: 'asc' })).toEqual(['我喜欢', '晨跑', 'Chill', '通勤', '安静的夜晚'])
|
||||
})
|
||||
|
||||
it('按歌曲数排序', () => {
|
||||
expect(names({ key: 'trackCount', order: 'desc' })).toEqual(['我喜欢', '通勤', '晨跑', 'Chill', '安静的夜晚'])
|
||||
expect(names({ key: 'trackCount', order: 'asc' })).toEqual(['我喜欢', '安静的夜晚', 'Chill', '晨跑', '通勤'])
|
||||
})
|
||||
|
||||
it('“我喜欢”的值排在最后时也置顶', () => {
|
||||
const empty = { ...favorites, name: '我喜欢', trackCount: 0, updatedAtMs: 0 }
|
||||
expect(sortPlaylists([commute, empty], { key: 'trackCount', order: 'desc' })[0]).toBe(empty)
|
||||
expect(sortPlaylists([commute, empty], { key: 'updatedAt', order: 'desc' })[0]).toBe(empty)
|
||||
})
|
||||
|
||||
it('值相同时依次按创建时间、id 排,与输入顺序无关', () => {
|
||||
const a = playlist('p7', '收藏', 4, 100, 900)
|
||||
const b = playlist('p5', '收藏', 4, 200, 900)
|
||||
const c = playlist('p6', '收藏', 4, 200, 900)
|
||||
for (const key of ['name', 'updatedAt', 'trackCount'] as const) {
|
||||
expect(sortPlaylists([c, b, a], { key, order: 'asc' })).toEqual([a, b, c])
|
||||
expect(sortPlaylists([a, c, b], { key, order: 'asc' })).toEqual([a, b, c])
|
||||
expect(sortPlaylists([b, a, c], { key, order: 'desc' })).toEqual([c, b, a])
|
||||
}
|
||||
})
|
||||
|
||||
it('不改动传入的数组', () => {
|
||||
const list = [commute, favorites, chill]
|
||||
sortPlaylists(list, { key: 'name', order: 'asc' })
|
||||
expect(list).toEqual([commute, favorites, chill])
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
|
||||
export type SortKey = 'name' | 'createdAt' | 'updatedAt' | 'trackCount'
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
export interface PlaylistSort {
|
||||
key: SortKey
|
||||
order: SortOrder
|
||||
}
|
||||
|
||||
export const sortKeys: SortKey[] = ['name', 'createdAt', 'updatedAt', 'trackCount']
|
||||
export const sortOrders: SortOrder[] = ['asc', 'desc']
|
||||
|
||||
// 换一个字段排序时的方向:名称从 A 到 Z,时间最新在前,歌曲数最多在前。
|
||||
export function defaultOrder(key: SortKey): SortOrder {
|
||||
return key === 'name' ? 'asc' : 'desc'
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
|
||||
|
||||
function compareBy(key: SortKey, a: MyPlaylist, b: MyPlaylist): number {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return collator.compare(a.name, b.name)
|
||||
case 'createdAt':
|
||||
return a.createdAtMs - b.createdAtMs
|
||||
case 'updatedAt':
|
||||
return a.updatedAtMs - b.updatedAtMs
|
||||
case 'trackCount':
|
||||
return a.trackCount - b.trackCount
|
||||
}
|
||||
}
|
||||
|
||||
function compareIds(a: MyPlaylist, b: MyPlaylist): number {
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
|
||||
}
|
||||
|
||||
// 方向作用于整个排序键,反向得到的正好是倒过来的列表;“我喜欢”不参与,始终在最前。
|
||||
export function sortPlaylists(list: readonly MyPlaylist[], { key, order }: PlaylistSort): MyPlaylist[] {
|
||||
const sign = order === 'asc' ? 1 : -1
|
||||
return [...list].sort((a, b) => Number(b.favorites) - Number(a.favorites)
|
||||
|| sign * (compareBy(key, a, b) || a.createdAtMs - b.createdAtMs || compareIds(a, b)))
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { KeyboardEvent, MouseEvent } from 'react'
|
||||
import type { MovePlan } from './track-order'
|
||||
import type { Selection } from './track-selection'
|
||||
import type { MyPlaylistItem, Track } from '@/core/realtime'
|
||||
import type { SongAction } from '@/shared/ui'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { sameTrack } from '@/core/room'
|
||||
import { sourceName } from '@/features/queue'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { formatDuration } from '@/shared/lib/time'
|
||||
import { AsyncIconButton, PlayingBars, SongArtwork } from '@/shared/ui'
|
||||
import { trackKey } from './playlists-store'
|
||||
import { moveByOne } from './track-order'
|
||||
import { selectAll, selectRange, toggle } from './track-selection'
|
||||
import { useTrackDrag } from './use-track-drag'
|
||||
|
||||
export function PlaylistTracks({ items, playing, selection, selecting, actions, onSelect, onMove }: {
|
||||
items: MyPlaylistItem[]
|
||||
playing: Track | undefined
|
||||
selection: Selection
|
||||
selecting: boolean
|
||||
actions: (item: MyPlaylistItem) => SongAction[]
|
||||
onSelect: (selection: Selection) => void
|
||||
onMove: (plan: MovePlan<string>) => void
|
||||
}) {
|
||||
const listRef = useRef<HTMLOListElement>(null)
|
||||
const lineRef = useRef<HTMLDivElement>(null)
|
||||
const labelRef = useRef<HTMLDivElement>(null)
|
||||
const focusAfterMoveRef = useRef<string | null>(null)
|
||||
const keys = items.map(item => trackKey(item.track))
|
||||
const { dragging, onPointerDown } = useTrackDrag({
|
||||
listRef,
|
||||
lineRef,
|
||||
labelRef,
|
||||
keys,
|
||||
selected: selection.keys,
|
||||
onDrop: (plan) => {
|
||||
if (plan)
|
||||
onMove(plan)
|
||||
},
|
||||
})
|
||||
|
||||
// 重新排列时 React 可能把获得焦点的行移出再插回,焦点会丢。
|
||||
useLayoutEffect(() => {
|
||||
const key = focusAfterMoveRef.current
|
||||
if (key === null)
|
||||
return
|
||||
focusAfterMoveRef.current = null
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-key="${CSS.escape(key)}"]`)?.focus()
|
||||
}, [items])
|
||||
|
||||
const onRowClick = (event: MouseEvent, key: string) => {
|
||||
if ((event.target as Element).closest('button, a'))
|
||||
return
|
||||
if (event.shiftKey)
|
||||
onSelect(selectRange(selection, keys, key))
|
||||
else if (event.metaKey || event.ctrlKey || selecting)
|
||||
onSelect(toggle(selection, key))
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'a') {
|
||||
event.preventDefault()
|
||||
onSelect(selectAll(keys))
|
||||
return
|
||||
}
|
||||
// 其余按键只在焦点落在行本身时处理,行里的按钮保持原来的行为。
|
||||
const row = event.target as HTMLElement
|
||||
const key = row.dataset.key
|
||||
if (key === undefined)
|
||||
return
|
||||
if (event.key === ' ' && selecting) {
|
||||
event.preventDefault()
|
||||
onSelect(toggle(selection, key))
|
||||
return
|
||||
}
|
||||
if ((event.key !== 'ArrowUp' && event.key !== 'ArrowDown') || event.metaKey || event.ctrlKey || event.shiftKey)
|
||||
return
|
||||
event.preventDefault()
|
||||
const delta = event.key === 'ArrowUp' ? -1 : 1
|
||||
if (!event.altKey) {
|
||||
const next = delta < 0 ? row.previousElementSibling : row.nextElementSibling
|
||||
if (next instanceof HTMLElement)
|
||||
next.focus()
|
||||
return
|
||||
}
|
||||
const plan = moveByOne(keys, selection.keys.has(key) ? selection.keys : new Set([key]), delta)
|
||||
if (!plan)
|
||||
return
|
||||
focusAfterMoveRef.current = key
|
||||
onMove(plan)
|
||||
}
|
||||
|
||||
let label = ''
|
||||
if (dragging) {
|
||||
const [first] = dragging
|
||||
label = dragging.size > 1 ? `${dragging.size} 首` : items.find(item => trackKey(item.track) === first)?.meta.title ?? ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="playlist-tracks" data-dragging={dragging ? true : undefined}>
|
||||
<ol ref={listRef} className="song-list" onKeyDown={onKeyDown}>
|
||||
{items.map((item, index) => {
|
||||
const { track, meta } = item
|
||||
const key = keys[index]
|
||||
const selected = selection.keys.has(key)
|
||||
const source = sourceName(track)
|
||||
const isPlaying = sameTrack(track, playing)
|
||||
return (
|
||||
<li
|
||||
key={key}
|
||||
data-key={key}
|
||||
className="song-row playlist-track"
|
||||
tabIndex={0}
|
||||
data-playing={isPlaying || undefined}
|
||||
data-selected={selected || undefined}
|
||||
data-dragging={dragging?.has(key) || undefined}
|
||||
onClick={event => onRowClick(event, key)}
|
||||
onPointerDown={event => onPointerDown(event, key)}
|
||||
>
|
||||
{selecting
|
||||
? <input type="checkbox" className="playlist-track-check" tabIndex={-1} checked={selected} readOnly aria-label={`选择${bookTitle(meta.title)}`} />
|
||||
: <span className="song-index text-ui-sm muted">{String(index + 1).padStart(2, '0')}</span>}
|
||||
{/* 多选时点封面也是切换选中,不打开音源页面。 */}
|
||||
<SongArtwork coverUrl={meta.coverUrl} title={meta.title} href={selecting ? undefined : meta.pageUrl} sourceName={source} />
|
||||
<div className="song-copy">
|
||||
<span className="song-title">
|
||||
{isPlaying && <PlayingBars />}
|
||||
<strong className="text-ui-base truncate">{meta.title}</strong>
|
||||
</span>
|
||||
<span className="text-ui-sm muted truncate">{[meta.artist || '未知艺术家', source].filter(Boolean).join(' · ')}</span>
|
||||
</div>
|
||||
<span className="song-album text-ui-sm muted truncate">{meta.album}</span>
|
||||
<span className="song-duration text-ui-sm muted">{meta.durationMs > 0 ? formatDuration(meta.durationMs) : ''}</span>
|
||||
{actions(item).map(action => (
|
||||
<AsyncIconButton key={action.label} icon={action.icon} className={action.secondary ? 'song-action song-action-secondary' : 'song-action'} label={action.label} disabled={action.disabled} onAction={action.run} />
|
||||
))}
|
||||
<span className="playlist-track-handle" data-drag-handle aria-hidden="true">
|
||||
<GripVertical size={18} strokeWidth={1.7} />
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{dragging && <div ref={lineRef} className="playlist-drop-line" />}
|
||||
{dragging && createPortal(<div ref={labelRef} className="playlist-drag-label text-ui-sm">{label}</div>, document.body)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { PlaylistSort, SortKey, SortOrder } from './playlist-sort'
|
||||
import type { ViewPreference } from './view-preference'
|
||||
import type { MyPlaylist } from '@/core/realtime'
|
||||
import { ArrowDown, ArrowDownAZ, ArrowDownNarrowWide, ArrowDownWideNarrow, ArrowDownZA, ArrowUp, LayoutGrid, List, Plus, Star } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, IconButton, Modal, notificationStore, Select, Skeleton } from '@/shared/ui'
|
||||
import { CreatePlaylistForm } from './create-playlist-form'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { formatPlaylistDate } from './playlist-date'
|
||||
import { PlaylistMenu } from './playlist-menu'
|
||||
import { defaultOrder, sortKeys, sortPlaylists } from './playlist-sort'
|
||||
import { playlistsStore } from './playlists-store'
|
||||
import { loadViewPreference, saveViewPreference } from './view-preference'
|
||||
import './playlists.css'
|
||||
|
||||
const sortLabels: Record<SortKey, string> = { name: '名称', createdAt: '创建时间', updatedAt: '更新时间', trackCount: '歌曲数' }
|
||||
const sortOptions = sortKeys.map(value => ({ value, label: sortLabels[value] }))
|
||||
const columns: SortKey[] = ['name', 'trackCount', 'updatedAt', 'createdAt']
|
||||
|
||||
const orderLabels: Record<SortKey, Record<SortOrder, string>> = {
|
||||
name: { asc: 'A–Z', desc: 'Z–A' },
|
||||
createdAt: { asc: '最早在前', desc: '最新在前' },
|
||||
updatedAt: { asc: '最早在前', desc: '最新在前' },
|
||||
trackCount: { asc: '最少在前', desc: '最多在前' },
|
||||
}
|
||||
|
||||
function orderIcon({ key, order }: PlaylistSort): LucideIcon {
|
||||
if (key === 'name')
|
||||
return order === 'asc' ? ArrowDownAZ : ArrowDownZA
|
||||
return order === 'asc' ? ArrowDownNarrowWide : ArrowDownWideNarrow
|
||||
}
|
||||
|
||||
function reversed(order: SortOrder): SortOrder {
|
||||
return order === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
function loadProfile(): void {
|
||||
void profileStore.load().catch((error: unknown) => {
|
||||
notificationStore.error(errorMessage(error))
|
||||
})
|
||||
}
|
||||
|
||||
export function PlaylistsPage({ onOpen, onRegister }: { onOpen: (playlist: MyPlaylist) => void, onRegister: () => void }) {
|
||||
const member = useStore(profileStore, s => s.member)
|
||||
const account = useStore(profileStore, s => s.account)
|
||||
const playlists = useStore(playlistsStore, s => s.playlists)
|
||||
const failed = useStore(playlistsStore, s => s.failed)
|
||||
const profileFailed = useStore(profileStore, s => s.failed)
|
||||
const [preference, setPreference] = useState(loadViewPreference)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [now] = useState(Date.now)
|
||||
useEffect(() => {
|
||||
if (!member)
|
||||
loadProfile()
|
||||
}, [member])
|
||||
useEffect(() => {
|
||||
if (account)
|
||||
void playlistsStore.load()
|
||||
}, [account])
|
||||
const { view, sort } = preference
|
||||
const update = (next: ViewPreference) => {
|
||||
saveViewPreference(next)
|
||||
setPreference(next)
|
||||
}
|
||||
const sortBy = (key: SortKey) => update({ view, sort: { key, order: key === sort.key ? reversed(sort.order) : defaultOrder(key) } })
|
||||
const shown = account ? playlists : null
|
||||
let body
|
||||
if (!member) {
|
||||
body = profileFailed
|
||||
? <button type="button" className="button-secondary" onClick={loadProfile}>重试</button>
|
||||
: <Skeleton rows={4} />
|
||||
}
|
||||
else if (!account) {
|
||||
body = (
|
||||
<EmptyState icon={Star} title="收藏你喜欢的歌" action={<button type="button" className="button-secondary" onClick={onRegister}>去注册</button>}>
|
||||
注册账号之后,就能收藏歌曲、建自己的歌单,在其他设备上也能看到。
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
else if (playlists === null) {
|
||||
body = failed
|
||||
? <button type="button" className="button-secondary" onClick={() => void playlistsStore.load()}>重试</button>
|
||||
: <Skeleton rows={4} />
|
||||
}
|
||||
else {
|
||||
const sorted = sortPlaylists(playlists, sort)
|
||||
body = view === 'list'
|
||||
? <PlaylistTable playlists={sorted} sort={sort} now={now} onSort={sortBy} onOpen={onOpen} />
|
||||
: <PlaylistGrid playlists={sorted} now={now} onOpen={onOpen} />
|
||||
}
|
||||
return (
|
||||
<section className="page-content playlists-page" aria-labelledby="playlists-title">
|
||||
<header className="playlists-toolbar">
|
||||
<div className="playlists-heading">
|
||||
<h1 id="playlists-title" className="text-ui-xl">我的歌单</h1>
|
||||
{shown && <p className="text-ui-sm muted">{`${shown.length} 个歌单`}</p>}
|
||||
</div>
|
||||
{shown && (
|
||||
<>
|
||||
<div className="playlists-controls">
|
||||
<div className="playlists-view-switch" role="group" aria-label="视图">
|
||||
<IconButton icon={List} label="列表" aria-pressed={view === 'list'} onClick={() => update({ view: 'list', sort })} />
|
||||
<IconButton icon={LayoutGrid} label="网格" aria-pressed={view === 'grid'} onClick={() => update({ view: 'grid', sort })} />
|
||||
</div>
|
||||
<div className="playlists-sort">
|
||||
<Select
|
||||
className="playlists-sort-key"
|
||||
ariaLabel={`排序方式:${sortLabels[sort.key]}`}
|
||||
value={sort.key}
|
||||
options={sortOptions}
|
||||
onChange={(value) => {
|
||||
const key = value as SortKey
|
||||
update({ view, sort: { key, order: key === sort.key ? sort.order : defaultOrder(key) } })
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
className="playlists-sort-order"
|
||||
icon={orderIcon(sort)}
|
||||
label={orderLabels[sort.key][sort.order]}
|
||||
onClick={() => update({ view, sort: { key: sort.key, order: reversed(sort.order) } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="button-primary playlists-create" onClick={() => setCreating(true)}>
|
||||
<Plus size={18} strokeWidth={2} aria-hidden="true" />
|
||||
新建歌单
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
{body}
|
||||
{creating && <CreateDialog onClose={() => setCreating(false)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaylistTable({ playlists, sort, now, onSort, onOpen }: {
|
||||
playlists: MyPlaylist[]
|
||||
sort: PlaylistSort
|
||||
now: number
|
||||
onSort: (key: SortKey) => void
|
||||
onOpen: (playlist: MyPlaylist) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="playlist-table" role="table" aria-label="我的歌单">
|
||||
<div className="playlist-table-head" role="rowgroup">
|
||||
<div className="playlist-table-row" role="row">
|
||||
{columns.map((key) => {
|
||||
const active = key === sort.key
|
||||
const Arrow = sort.order === 'asc' ? ArrowUp : ArrowDown
|
||||
return (
|
||||
<div key={key} className={`playlist-col-${key}`} role="columnheader" aria-sort={active ? (sort.order === 'asc' ? 'ascending' : 'descending') : undefined}>
|
||||
<button type="button" className="playlist-sort-button text-ui-sm" data-active={active || undefined} onClick={() => onSort(key)}>
|
||||
{sortLabels[key]}
|
||||
{active && <Arrow size={14} strokeWidth={2} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="playlist-col-menu" role="columnheader"><span className="sr-only">操作</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div role="rowgroup">
|
||||
{playlists.map(playlist => (
|
||||
<div key={playlist.id} className="playlist-table-row playlist-row" role="row">
|
||||
<div className="playlist-col-name" role="cell">
|
||||
<button type="button" className="playlist-row-open" onClick={() => onOpen(playlist)}>
|
||||
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="sm" />
|
||||
<span className="playlist-row-copy">
|
||||
<span className="playlist-row-title">
|
||||
<strong className="text-ui-base truncate">{playlist.name}</strong>
|
||||
{playlist.favorites && <FavoritesMark />}
|
||||
</span>
|
||||
<span className="playlist-row-count text-ui-sm muted">{`${playlist.trackCount} 首`}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="playlist-col-trackCount text-ui-sm muted" role="cell">{playlist.trackCount}</div>
|
||||
<div className="playlist-col-updatedAt text-ui-sm muted" role="cell">{formatPlaylistDate(playlist.updatedAtMs, now)}</div>
|
||||
<div className="playlist-col-createdAt text-ui-sm muted" role="cell">{formatPlaylistDate(playlist.createdAtMs, now)}</div>
|
||||
<div className="playlist-col-menu" role="cell">
|
||||
{!playlist.favorites && <PlaylistMenu playlist={playlist} />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaylistGrid({ playlists, now, onOpen }: { playlists: MyPlaylist[], now: number, onOpen: (playlist: MyPlaylist) => void }) {
|
||||
return (
|
||||
<ul className="playlist-grid">
|
||||
{playlists.map(playlist => (
|
||||
<li key={playlist.id} className="playlist-card">
|
||||
<button type="button" className="playlist-card-open" onClick={() => onOpen(playlist)}>
|
||||
<span className="playlist-card-art">
|
||||
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="md" />
|
||||
</span>
|
||||
<strong className="text-ui-base truncate">{playlist.name}</strong>
|
||||
<span className="text-ui-sm muted truncate">{`${playlist.trackCount} 首 · ${formatPlaylistDate(playlist.updatedAtMs, now)}`}</span>
|
||||
</button>
|
||||
{!playlist.favorites && <PlaylistMenu playlist={playlist} className="playlist-card-menu" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateDialog({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Modal title="新建歌单" onClose={onClose}>
|
||||
<CreatePlaylistForm onCreated={onClose} onCancel={onClose} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
import type { Track } from '@/core/realtime'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
class FakeStorage {
|
||||
readonly #items = new Map<string, string>()
|
||||
|
||||
get length(): number {
|
||||
return this.#items.size
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return [...this.#items.keys()][index] ?? null
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.#items.get(key) ?? null
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.#items.set(key, value)
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.#items.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const favorites = { id: 'p1', name: '我喜欢', favorites: true, trackCount: 1, createdAtMs: 1, updatedAtMs: 1, coverUrl: null }
|
||||
const sunny = { source: 'netease', id: '186016' } as const
|
||||
const rain = { source: 'netease', id: '185811' } as const
|
||||
const commute = { id: 'p2', name: '通勤', favorites: false, trackCount: 0, createdAtMs: 1, updatedAtMs: 1, coverUrl: null }
|
||||
const meta = { title: '晴天', artist: '周杰伦', album: '叶惠美', coverUrl: '', pageUrl: '', durationMs: 0 }
|
||||
const notReady = '这首歌还在准备,准备好之后再保存'
|
||||
const full = '一个歌单最多 1000 首歌'
|
||||
const changed = '歌单已经变了,刷新之后再试'
|
||||
|
||||
let requests: string[]
|
||||
let release: (() => void)[]
|
||||
let failRemove: boolean
|
||||
let failAdd: boolean
|
||||
let failCopy: boolean
|
||||
let failMove: boolean
|
||||
let bodies: unknown[]
|
||||
let holdReads: boolean
|
||||
let serverPlaylists: typeof favorites[]
|
||||
let server: Map<string, Map<string, Track>>
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status })
|
||||
}
|
||||
|
||||
function key(track: Track): string {
|
||||
return `${track.source}:${track.id}`
|
||||
}
|
||||
|
||||
function items(playlist: string): Map<string, Track> {
|
||||
let tracks = server.get(playlist)
|
||||
if (!tracks) {
|
||||
tracks = new Map()
|
||||
server.set(playlist, tracks)
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
// 读取的结果在请求到达服务端时确定,holdReads 时推迟返回,模拟带着旧状态返回的读取。
|
||||
async function read(body: unknown): Promise<Response> {
|
||||
if (holdReads)
|
||||
await new Promise<void>(resolve => release.push(resolve))
|
||||
return json(200, body)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
requests = []
|
||||
release = []
|
||||
failRemove = false
|
||||
failAdd = false
|
||||
failCopy = false
|
||||
failMove = false
|
||||
bodies = []
|
||||
holdReads = false
|
||||
serverPlaylists = [favorites]
|
||||
server = new Map([['p1', new Map([[key(sunny), sunny]])]])
|
||||
vi.stubGlobal('window', new EventTarget())
|
||||
const local = new FakeStorage()
|
||||
local.setItem('alisten.member-token', 'token')
|
||||
vi.stubGlobal('localStorage', local)
|
||||
vi.stubGlobal('fetch', async (path: string, init: RequestInit) => {
|
||||
const request = `${init.method} ${path}`
|
||||
requests.push(request)
|
||||
if (request === 'GET /api/me/playlists')
|
||||
return read({ items: [...serverPlaylists] })
|
||||
if (request === 'POST /api/me/playlist-memberships') {
|
||||
const { track } = JSON.parse(String(init.body)) as { track: Track }
|
||||
return read({ playlistIds: [...server].filter(([, tracks]) => tracks.has(key(track))).map(([id]) => id) })
|
||||
}
|
||||
// 让测试控制请求完成的先后,模拟连续点击。
|
||||
await new Promise<void>(resolve => release.push(resolve))
|
||||
if (request === 'DELETE /api/me/playlists/p2') {
|
||||
serverPlaylists = serverPlaylists.filter(p => p.id !== 'p2')
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request === 'POST /api/me/playlists') {
|
||||
serverPlaylists.push(commute)
|
||||
return json(201, commute)
|
||||
}
|
||||
if (init.body)
|
||||
bodies.push(JSON.parse(String(init.body)))
|
||||
const write = /^POST \/api\/me\/playlists\/(\w+)\/(items|removals|copies|moves)$/.exec(request)
|
||||
if (write?.[2] === 'items') {
|
||||
if (failAdd)
|
||||
return json(409, { error: { code: 'CONFLICT', message: notReady } })
|
||||
const { track } = JSON.parse(String(init.body)) as { track: Track }
|
||||
items(write[1]).set(key(track), track)
|
||||
return json(201, { item: { track, meta } })
|
||||
}
|
||||
if (write?.[2] === 'removals') {
|
||||
if (failRemove)
|
||||
return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } })
|
||||
const { tracks } = JSON.parse(String(init.body)) as { tracks: Track[] }
|
||||
for (const track of tracks)
|
||||
items(write[1]).delete(key(track))
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (write?.[2] === 'copies') {
|
||||
if (failCopy)
|
||||
return json(409, { error: { code: 'CONFLICT', message: full } })
|
||||
const { tracks } = JSON.parse(String(init.body)) as { tracks: Track[] }
|
||||
const target = items(write[1])
|
||||
const added = tracks.filter(track => !target.has(key(track)))
|
||||
for (const track of added)
|
||||
target.set(key(track), track)
|
||||
return json(200, { added: added.length })
|
||||
}
|
||||
if (write?.[2] === 'moves') {
|
||||
if (failMove)
|
||||
return json(409, { error: { code: 'CONFLICT', message: changed } })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
throw new Error(`没有预期的请求 ${request}`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function loaded() {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
await playlistsStore.load()
|
||||
await playlistsStore.containing(sunny)
|
||||
await playlistsStore.containing(rain)
|
||||
requests = []
|
||||
const included = (track: Track) => playlistsStore.state.memberships.get(key(track))?.has('p1') ?? false
|
||||
return { playlistsStore, included }
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
// 每次先等一个宏任务,让已经开始的请求走到 fetch,再放行最早的一个。
|
||||
async function settle() {
|
||||
for (;;) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const next = release.shift()
|
||||
if (!next)
|
||||
return
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
const writes = () => requests.filter(r => !r.startsWith('GET ') && r !== 'POST /api/me/playlist-memberships')
|
||||
|
||||
describe('playlistsStore', () => {
|
||||
it('load 读取歌单', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
await playlistsStore.load()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
expect(requests).toEqual(['GET /api/me/playlists'])
|
||||
})
|
||||
|
||||
it('containing 读取包含这首歌的歌单', async () => {
|
||||
const { included } = await loaded()
|
||||
expect(included(sunny)).toBe(true)
|
||||
expect(included(rain)).toBe(false)
|
||||
})
|
||||
|
||||
it('加入请求进行中又取消勾选,完成后再发移出', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
await settle()
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlists/p1/removals'])
|
||||
expect(included(rain)).toBe(false)
|
||||
})
|
||||
|
||||
it('连点三次只发一次加入', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await settle()
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items'])
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
it('第一次请求失败时以服务端为准,提示服务端的文案', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failAdd = true
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await settle()
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items'])
|
||||
expect(included(rain)).toBe(false)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([notReady])
|
||||
})
|
||||
|
||||
it('移出失败时恢复勾选', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
failRemove = true
|
||||
const done = playlistsStore.setMembership(favorites, sunny, false)
|
||||
expect(included(sunny)).toBe(false)
|
||||
await settle()
|
||||
await done
|
||||
expect(included(sunny)).toBe(true)
|
||||
})
|
||||
|
||||
it('加入成功后移出失败,勾选恢复为已加入', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
failRemove = true
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
await settle()
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlists/p1/removals'])
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
it('读取期间正在加入的歌以本地为准', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await flush()
|
||||
await playlistsStore.containing(rain)
|
||||
expect(included(rain)).toBe(true)
|
||||
await settle()
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
it('读取期间正在移出的歌以本地为准', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, sunny, false)
|
||||
await flush()
|
||||
await playlistsStore.containing(sunny)
|
||||
expect(included(sunny)).toBe(false)
|
||||
await settle()
|
||||
expect(included(sunny)).toBe(false)
|
||||
})
|
||||
|
||||
it('同步完成之后才返回的旧读取不会取消勾选', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
holdReads = true
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await flush()
|
||||
void playlistsStore.containing(rain)
|
||||
await flush()
|
||||
// 先放行加入,同步结束;再放行加入之前发出的读取。
|
||||
release.shift()!()
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
expect(included(rain)).toBe(true)
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
it('同步结束后重新读取歌单和这首歌所在的歌单', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await settle()
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlist-memberships', 'GET /api/me/playlists'])
|
||||
})
|
||||
|
||||
it('removeItems 一次移出多首,成功后重新读取歌单', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
items('p1').set(key(rain), rain)
|
||||
const done = playlistsStore.removeItems(favorites, [sunny, rain])
|
||||
await settle()
|
||||
expect(await done).toBe(true)
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p1/removals', 'GET /api/me/playlists'])
|
||||
expect(bodies).toEqual([{ tracks: [sunny, rain] }])
|
||||
expect(items('p1').size).toBe(0)
|
||||
})
|
||||
|
||||
it('removeItems 失败时提示并返回 false', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failRemove = true
|
||||
const done = playlistsStore.removeItems(favorites, [sunny])
|
||||
await settle()
|
||||
expect(await done).toBe(false)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['暂时不可用'])
|
||||
})
|
||||
|
||||
it('copyItems 返回新加入的首数,目标歌单里已有的歌不算', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
items('p2').set(key(sunny), sunny)
|
||||
const done = playlistsStore.copyItems(favorites, commute, [sunny, rain])
|
||||
await settle()
|
||||
expect(await done).toBe(1)
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p2/copies', 'GET /api/me/playlists'])
|
||||
expect(bodies).toEqual([{ from: 'p1', tracks: [sunny, rain] }])
|
||||
})
|
||||
|
||||
it('copyItems 装不下时提示是哪个歌单并返回 null', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failCopy = true
|
||||
const done = playlistsStore.copyItems(favorites, commute, [sunny])
|
||||
await settle()
|
||||
expect(await done).toBeNull()
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([`没能保存到“通勤”:${full}`])
|
||||
})
|
||||
|
||||
it('moveItems 有 before 时放到它之前,没有时移到最后', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const first = playlistsStore.moveItems(favorites, [rain], sunny)
|
||||
await settle()
|
||||
const last = playlistsStore.moveItems(favorites, [sunny])
|
||||
await settle()
|
||||
expect([await first, await last]).toEqual([true, true])
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/moves', 'POST /api/me/playlists/p1/moves'])
|
||||
expect(bodies).toEqual([{ tracks: [rain], before: sunny }, { tracks: [sunny] }])
|
||||
expect(requests.filter(r => r === 'GET /api/me/playlists')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('moveItems 失败时提示并返回 false', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failMove = true
|
||||
const done = playlistsStore.moveItems(favorites, [sunny])
|
||||
await settle()
|
||||
expect(await done).toBe(false)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed])
|
||||
})
|
||||
|
||||
it('游客不读取歌单', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
playlistsStore.start()
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await flush()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
it('注册之后读取,窗口获得焦点时刷新', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await flush()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await flush()
|
||||
expect(requests.filter(r => r === 'GET /api/me/playlists')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('换账号之后,上一个账号的读取结果作废', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
holdReads = true
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'bob' })
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
expect(playlistsStore.state.playlists).toBeNull()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
})
|
||||
|
||||
it('换账号之后,上一个账号读到的所在歌单作废', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await flush()
|
||||
holdReads = true
|
||||
const reading = playlistsStore.containing(sunny)
|
||||
await flush()
|
||||
await profileStore.registered({ username: 'bob' })
|
||||
release.shift()!()
|
||||
await reading
|
||||
expect(playlistsStore.state.memberships.size).toBe(0)
|
||||
})
|
||||
|
||||
// 读取在删除或新建之前到达服务端,却在之后才返回时,结果不能写回界面。
|
||||
it('读取进行中删除歌单,旧读取返回后歌单不会回来', async () => {
|
||||
serverPlaylists.push(commute)
|
||||
const { playlistsStore } = await loaded()
|
||||
holdReads = true
|
||||
void playlistsStore.load()
|
||||
await flush()
|
||||
const removed = playlistsStore.remove(commute)
|
||||
await flush()
|
||||
release.splice(1, 1)[0]()
|
||||
await removed
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
})
|
||||
|
||||
it('读取进行中新建歌单,旧读取返回后歌单不会消失', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
holdReads = true
|
||||
void playlistsStore.load()
|
||||
await flush()
|
||||
const created = playlistsStore.create('通勤')
|
||||
await flush()
|
||||
release.splice(1, 1)[0]()
|
||||
await created
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
|
||||
})
|
||||
})
|
||||
@@ -1,243 +0,0 @@
|
||||
import type { MyPlaylist, Track } from '@/core/realtime'
|
||||
import { api } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { Store } from '@/shared/lib/store'
|
||||
import { notificationStore } from '@/shared/ui'
|
||||
|
||||
export function trackKey(track: Track): string {
|
||||
return `${track.source}:${track.id}`
|
||||
}
|
||||
|
||||
function membershipKey(playlistId: string, track: Track): string {
|
||||
return `${playlistId} ${trackKey(track)}`
|
||||
}
|
||||
|
||||
interface PlaylistsState {
|
||||
// 游客,或者还没有读到时为 null。
|
||||
playlists: MyPlaylist[] | null
|
||||
failed: boolean
|
||||
// 曲目 → 包含它的歌单 id,只有读过的曲目才有。正在同步的歌单是最后一次勾选的结果,同步负责把服务端改成一致。
|
||||
memberships: ReadonlyMap<string, ReadonlySet<string>>
|
||||
}
|
||||
|
||||
const empty: PlaylistsState = { playlists: null, failed: false, memberships: new Map() }
|
||||
|
||||
const nameTexts = { INVALID_ARGUMENT: '歌单名要 1 到 50 个字。' }
|
||||
|
||||
class PlaylistsStore extends Store<PlaylistsState> {
|
||||
#account: string | null = null
|
||||
// 只有最后发起的读取能写入:换了账号,或者之后又读过一次时,旧的结果作废。
|
||||
#generation = 0
|
||||
#membershipReads = new Map<string, object>()
|
||||
#syncing = new Map<string, Promise<void>>()
|
||||
|
||||
constructor() {
|
||||
super(empty)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
profileStore.addEventListener('change', this.#onProfile)
|
||||
window.addEventListener('focus', this.#onFocus)
|
||||
this.#onProfile()
|
||||
}
|
||||
|
||||
// 失败时只设置 failed,不抛出。
|
||||
load(): Promise<void> {
|
||||
return this.#load(++this.#generation)
|
||||
}
|
||||
|
||||
// 读取包含这首歌的歌单,写入 memberships;没读到时返回 false。读到了却已被之后的读取取代时也返回 true,由后来的读取写入。
|
||||
async containing(track: Track): Promise<boolean> {
|
||||
const account = this.#account
|
||||
const key = trackKey(track)
|
||||
const read = {}
|
||||
this.#membershipReads.set(key, read)
|
||||
try {
|
||||
const { playlistIds } = await api.listMyTrackPlaylists({ track })
|
||||
if (this.#account === account && this.#membershipReads.get(key) === read)
|
||||
this.#applyMemberships(track, playlistIds)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
if (this.#membershipReads.get(key) === read)
|
||||
this.#membershipReads.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
setMembership(playlist: MyPlaylist, track: Track, included: boolean): Promise<void> {
|
||||
const key = membershipKey(playlist.id, track)
|
||||
const saved = this.#includes(playlist.id, track)
|
||||
this.#setIncluded(playlist.id, track, included)
|
||||
let sync = this.#syncing.get(key)
|
||||
if (!sync) {
|
||||
sync = this.#sync(playlist.id, track, saved)
|
||||
this.#syncing.set(key, sync)
|
||||
void sync.finally(() => {
|
||||
if (this.#syncing.get(key) !== sync)
|
||||
return
|
||||
this.#syncing.delete(key)
|
||||
// 同步之前发出的读取可能带着旧的勾选返回,重新读一次让它们作废;请求超时但服务端已生效时,也以服务端为准。
|
||||
void this.containing(track)
|
||||
void this.load()
|
||||
})
|
||||
}
|
||||
return sync
|
||||
}
|
||||
|
||||
async create(name: string): Promise<MyPlaylist | null> {
|
||||
try {
|
||||
const playlist = await api.createMyPlaylist({ name })
|
||||
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })
|
||||
void this.load()
|
||||
return playlist
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error, nameTexts))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async rename(playlist: MyPlaylist, name: string): Promise<boolean> {
|
||||
try {
|
||||
await api.renameMyPlaylist(playlist.id, { name })
|
||||
await this.load()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error, nameTexts))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async remove(playlist: MyPlaylist): Promise<boolean> {
|
||||
try {
|
||||
await api.deleteMyPlaylist(playlist.id)
|
||||
this.setState({ playlists: (this.state.playlists ?? []).filter(p => p.id !== playlist.id) })
|
||||
void this.load()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async removeItems(playlist: MyPlaylist, tracks: Track[]): Promise<boolean> {
|
||||
try {
|
||||
await api.removeMyPlaylistItems(playlist.id, { tracks })
|
||||
void this.load()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回新加入的首数,to 里已经有的歌不算;失败时返回 null。
|
||||
async copyItems(from: MyPlaylist, to: MyPlaylist, tracks: Track[]): Promise<number | null> {
|
||||
try {
|
||||
const { added } = await api.copyMyPlaylistItems(to.id, { from: from.id, tracks })
|
||||
void this.load()
|
||||
return added
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(`没能保存到“${to.name}”:${errorMessage(error)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 before 时移到最后。
|
||||
async moveItems(playlist: MyPlaylist, tracks: Track[], before?: Track): Promise<boolean> {
|
||||
try {
|
||||
await api.moveMyPlaylistItems(playlist.id, before ? { tracks, before } : { tracks })
|
||||
void this.load()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 请求进行中又改了勾选时,等它完成之后按最新的勾选再发;失败时恢复成服务端的状态,不再发出。
|
||||
async #sync(playlistId: string, track: Track, saved: boolean): Promise<void> {
|
||||
const account = this.#account
|
||||
while (this.#account === account && this.#includes(playlistId, track) !== saved) {
|
||||
const included = this.#includes(playlistId, track)
|
||||
try {
|
||||
if (included)
|
||||
await api.addMyPlaylistItem(playlistId, { track })
|
||||
else
|
||||
await api.removeMyPlaylistItems(playlistId, { tracks: [track] })
|
||||
saved = included
|
||||
}
|
||||
catch (error) {
|
||||
if (this.#account === account)
|
||||
this.#setIncluded(playlistId, track, saved)
|
||||
notificationStore.error(errorMessage(error))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #load(generation: number): Promise<void> {
|
||||
try {
|
||||
const { items } = await api.listMyPlaylists()
|
||||
if (generation === this.#generation)
|
||||
this.setState({ playlists: items, failed: false })
|
||||
}
|
||||
catch {
|
||||
if (generation === this.#generation)
|
||||
this.setState({ failed: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 正在同步的歌单以本地为准:读到的可能是同步完成之前的状态。
|
||||
#applyMemberships(track: Track, playlistIds: readonly string[]): void {
|
||||
const syncing = (id: string) => this.#syncing.has(membershipKey(id, track))
|
||||
const next = new Set(playlistIds.filter(id => !syncing(id)))
|
||||
for (const id of this.state.memberships.get(trackKey(track)) ?? []) {
|
||||
if (syncing(id))
|
||||
next.add(id)
|
||||
}
|
||||
this.setState({ memberships: new Map(this.state.memberships).set(trackKey(track), next) })
|
||||
}
|
||||
|
||||
#includes(playlistId: string, track: Track): boolean {
|
||||
return this.state.memberships.get(trackKey(track))?.has(playlistId) ?? false
|
||||
}
|
||||
|
||||
#setIncluded(playlistId: string, track: Track, included: boolean): void {
|
||||
const ids = new Set(this.state.memberships.get(trackKey(track)))
|
||||
if (included)
|
||||
ids.add(playlistId)
|
||||
else
|
||||
ids.delete(playlistId)
|
||||
this.setState({ memberships: new Map(this.state.memberships).set(trackKey(track), ids) })
|
||||
}
|
||||
|
||||
#onProfile = (): void => {
|
||||
const username = profileStore.state.account?.username ?? null
|
||||
if (username === this.#account)
|
||||
return
|
||||
this.#account = username
|
||||
this.#generation++
|
||||
this.#membershipReads.clear()
|
||||
this.#syncing.clear()
|
||||
this.setState(empty)
|
||||
if (username)
|
||||
void this.load()
|
||||
}
|
||||
|
||||
#onFocus = (): void => {
|
||||
if (this.#account)
|
||||
void this.load()
|
||||
}
|
||||
}
|
||||
|
||||
export const playlistsStore = new PlaylistsStore()
|
||||
@@ -1,161 +0,0 @@
|
||||
.save-playlist-subject { margin-bottom: 12px; }
|
||||
.save-playlist-list { display: flex; flex-direction: column; gap: 2px; max-height: min(52dvh, 432px); margin: 0 -12px; padding: 0; overflow-y: auto; overscroll-behavior: contain; list-style: none; }
|
||||
.save-playlist-row { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 6px 12px; border-radius: 8px; cursor: pointer; }
|
||||
.save-playlist-row:hover { background: var(--color-surface-hover); }
|
||||
.save-playlist-row:has(input:focus-visible) { outline: 2px solid var(--color-brand); outline-offset: -2px; }
|
||||
.save-playlist-row > .playlist-cover-sm { width: 36px; }
|
||||
.save-playlist-name { display: flex; flex: 1; align-items: center; gap: 6px; min-width: 0; }
|
||||
.save-playlist-row > .muted { flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||
/* 勾选框的位置空着,加号图块和上面各行的封面对齐。 */
|
||||
.save-playlist-new { width: 100%; padding-left: 40px; border: 0; background: none; color: inherit; text-align: left; }
|
||||
.save-playlist-new:focus-visible { outline-offset: -2px; }
|
||||
.save-playlist-new-tile { display: grid; place-items: center; width: 36px; aspect-ratio: 1; border-radius: 8px; color: var(--color-foreground-subtle); }
|
||||
.save-playlist-new-tile > svg { width: 20px; height: 20px; }
|
||||
/* 深色主题下对话框的底色和 --color-card-hover 几乎相同,占位图块会看不见。 */
|
||||
.save-playlist-new-tile, .save-playlist-row > .playlist-cover:not([data-favorites]) { background: var(--color-hover); }
|
||||
|
||||
.playlist-cover { position: relative; display: grid; grid-template: minmax(0, 1fr) / minmax(0, 1fr); place-items: center; flex-shrink: 0; aspect-ratio: 1; border-radius: 8px; background: var(--color-card-hover); color: var(--color-foreground-subtlest); }
|
||||
.playlist-cover[data-favorites] { background: var(--color-accent); color: var(--color-brand); }
|
||||
.playlist-cover > svg { width: 38%; height: 38%; }
|
||||
.playlist-cover > img { display: block; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
|
||||
.playlist-cover-sm { width: 40px; }
|
||||
.playlist-cover-md { width: 100%; }
|
||||
.playlist-cover-lg { width: 232px; border-radius: 10px; }
|
||||
/* z-index 为负:光晕画在最近的层叠上下文的最底层,在文字之下。放大封面的容器要用 isolation 建立层叠上下文,否则光晕会落到页面背景下面。 */
|
||||
.playlist-cover-glow { position: absolute; inset: 0; z-index: -1; opacity: var(--glow-opacity); filter: blur(24px) saturate(1.8); transform: translateY(10%) scale(.95); transition: opacity 240ms ease, transform 240ms ease; pointer-events: none; }
|
||||
.playlist-cover-lg > .playlist-cover-glow { filter: blur(32px) saturate(1.8); }
|
||||
:root:is([data-performance='low'], [data-performance='off']) .playlist-cover-glow { display: none; }
|
||||
.playlist-cover-badge { position: absolute; bottom: 8px; left: 8px; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--color-primary); color: var(--color-primary-foreground); box-shadow: 0 1px 4px rgb(0 0 0 / 35%); }
|
||||
.playlist-cover-badge > svg { width: 13px; height: 13px; }
|
||||
.playlist-cover-lg > .playlist-cover-badge { bottom: 12px; left: 12px; width: 32px; height: 32px; }
|
||||
.playlist-cover-lg > .playlist-cover-badge > svg { width: 17px; height: 17px; }
|
||||
.playlist-favorites-mark { flex-shrink: 0; color: var(--color-brand); }
|
||||
|
||||
.playlist-menu { inset: auto; margin: 0; min-width: 148px; padding: 6px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 12px 32px var(--color-shadow); transform-origin: top center; }
|
||||
.playlist-menu:popover-open { display: flex; flex-direction: column; gap: 2px; animation: popover-in 160ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
.playlist-menu-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); text-align: left; white-space: nowrap; }
|
||||
.playlist-menu-item:hover, .playlist-menu-item:focus-visible { outline: none; background: var(--color-hover); }
|
||||
.playlist-menu-item > svg { color: var(--color-foreground-subtle); }
|
||||
.playlist-menu-danger, .playlist-menu-danger > svg { color: var(--color-destructive); }
|
||||
.playlist-menu-button { color: var(--color-foreground-subtle); }
|
||||
.playlist-menu-button:hover:not(:disabled), .playlist-menu-button[aria-expanded='true'] { color: var(--color-foreground); }
|
||||
:root[data-performance='low'] .playlist-menu:popover-open { animation: fade-in 160ms ease both; }
|
||||
|
||||
.playlists-page { container: playlists / inline-size; }
|
||||
.playlists-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: 'heading controls create'; align-items: end; gap: 12px; margin-bottom: 28px; }
|
||||
.playlists-heading { grid-area: heading; min-width: 0; }
|
||||
.playlists-heading h1 { line-height: 1.25; }
|
||||
.playlists-heading p { margin-top: 4px; }
|
||||
.playlists-controls { grid-area: controls; display: flex; align-items: center; gap: 8px; }
|
||||
.playlists-create { grid-area: create; }
|
||||
.playlists-view-switch { display: flex; gap: 2px; padding: 2px; border: 1px solid var(--color-border); border-radius: 8px; }
|
||||
.playlists-view-switch .icon-button { width: 32px; height: 30px; border-radius: 6px; color: var(--color-foreground-subtle); }
|
||||
.playlists-view-switch .icon-button svg { width: 18px; height: 18px; }
|
||||
.playlists-view-switch .icon-button:hover:not(:disabled) { color: var(--color-foreground); }
|
||||
.playlists-view-switch .icon-button[aria-pressed='true'] { background: var(--color-selected); color: var(--color-foreground); }
|
||||
.playlists-sort { display: flex; }
|
||||
.playlists-sort-key .select-trigger { min-width: calc(4em + 48px); min-height: 36px; padding: 6px 10px 6px 12px; gap: 8px; border-color: var(--color-border); border-radius: 8px 0 0 8px; background: transparent; }
|
||||
.playlists-sort-order { width: 36px; height: 36px; border: 1px solid var(--color-border); border-left: 0; border-radius: 0 8px 8px 0; color: var(--color-foreground-subtle); }
|
||||
.playlists-sort-order svg { width: 18px; height: 18px; }
|
||||
.playlists-sort-order:hover:not(:disabled) { color: var(--color-foreground); }
|
||||
|
||||
.playlist-table { --playlist-columns: minmax(0, 1fr) 64px 132px 132px 36px; }
|
||||
.playlist-table-row { display: grid; grid-template-columns: var(--playlist-columns); align-items: center; gap: 24px; padding: 0 8px; }
|
||||
.playlist-table-head .playlist-table-row { min-height: 36px; margin-bottom: 6px; border-bottom: 1px solid var(--color-border); }
|
||||
.playlist-sort-button { display: inline-flex; align-items: center; gap: 4px; padding: 4px 0; border: 0; background: none; color: var(--color-foreground-subtle); white-space: nowrap; }
|
||||
.playlist-sort-button:hover, .playlist-sort-button[data-active] { color: var(--color-foreground); }
|
||||
.playlist-sort-button > svg { color: var(--color-brand); }
|
||||
.playlist-col-trackCount { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.playlist-col-updatedAt, .playlist-col-createdAt { overflow: hidden; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.playlist-col-menu { display: flex; justify-content: flex-end; }
|
||||
.playlist-row { position: relative; min-height: 56px; border-radius: 8px; }
|
||||
.playlist-row:hover { background: var(--color-surface); }
|
||||
.playlist-row-open { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; padding: 8px 0; border: 0; background: none; color: inherit; text-align: left; }
|
||||
/* 按钮只在名称这一格里,伸出的伪元素让整行都能点开歌单。 */
|
||||
.playlist-row-open::after { content: ''; position: absolute; inset: 0; border-radius: 8px; }
|
||||
.playlist-row-open:focus-visible { outline: none; }
|
||||
.playlist-row-open:focus-visible::after { outline: 2px solid var(--color-brand); outline-offset: -2px; }
|
||||
.playlist-row-open:active:not(:disabled) { transform: none; }
|
||||
.playlist-row-copy { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
.playlist-row-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.playlist-row-title > strong { font-weight: 500; }
|
||||
.playlist-row-count { display: none; }
|
||||
.playlist-row .playlist-menu-button { position: relative; z-index: 1; }
|
||||
|
||||
.playlist-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 28px 20px; margin: 0; padding: 0; list-style: none; }
|
||||
.playlist-card { position: relative; min-width: 0; transition: transform 200ms ease; }
|
||||
.playlist-card-open { isolation: isolate; display: flex; flex-direction: column; gap: 2px; width: 100%; min-width: 0; padding: 0; border: 0; border-radius: 10px; background: none; color: inherit; text-align: left; }
|
||||
.playlist-card-open:focus-visible { outline-offset: 6px; }
|
||||
.playlist-card-art { position: relative; display: block; margin-bottom: 12px; }
|
||||
.playlist-card-menu { position: absolute; top: 8px; right: 8px; z-index: 1; width: 32px; height: 32px; border-radius: 50%; background: var(--color-popover); color: var(--color-foreground); transition: opacity 150ms ease, background-color 150ms ease; }
|
||||
.playlist-card-menu:hover:not(:disabled), .playlist-card-menu[aria-expanded='true'] { background: var(--color-popover); color: var(--color-brand); }
|
||||
@media (hover: hover) {
|
||||
.playlist-row .playlist-menu-button, .playlist-card-menu { opacity: 0; }
|
||||
.playlist-row:hover .playlist-menu-button, .playlist-row:focus-within .playlist-menu-button,
|
||||
.playlist-card:hover .playlist-card-menu, .playlist-card:focus-within .playlist-card-menu,
|
||||
.playlist-menu-button[aria-expanded='true'] { opacity: 1; }
|
||||
.playlist-card:is(:hover, :has(:focus-visible)) .playlist-cover-glow { opacity: calc(var(--glow-opacity) + .2); transform: translateY(12%) scale(1); }
|
||||
}
|
||||
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
|
||||
:root:not([data-performance='low'], [data-performance='off'], [data-reduced-motion]) .playlist-card:is(:hover, :has(:focus-visible)) { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
.my-playlist-page { container: my-playlist / inline-size; }
|
||||
.my-playlist-back { margin: -6px 0 16px -8px; color: var(--color-foreground-subtle); }
|
||||
.my-playlist-back:hover:not(:disabled) { color: var(--color-foreground); }
|
||||
.my-playlist-header { isolation: isolate; display: flex; align-items: flex-end; gap: 28px; margin-bottom: 16px; padding-bottom: 28px; border-bottom: 1px solid var(--color-border); }
|
||||
.my-playlist-info { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||
.my-playlist-name { display: flex; align-items: center; gap: 10px; max-width: 100%; font-size: calc(var(--text-ui-xl) + 8px); font-weight: 700; line-height: 1.2; }
|
||||
.my-playlist-name > span { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
|
||||
.my-playlist-actions { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||
.my-playlist-select { min-width: 64px; border-radius: 999px; }
|
||||
.my-playlist-menu { border: 1px solid var(--color-border); border-radius: 50%; }
|
||||
.my-playlist-hint { margin-top: 12px; }
|
||||
|
||||
.playlist-tracks { position: relative; }
|
||||
.playlist-track { user-select: none; -webkit-user-select: none; }
|
||||
.playlist-track:focus-visible { outline: 2px solid var(--color-brand); outline-offset: -2px; }
|
||||
.playlist-track[data-selected], .playlist-track[data-selected]:hover { background: var(--color-selected); }
|
||||
.playlist-track[data-dragging] { opacity: .4; }
|
||||
.playlist-track-check { margin: 0 3px; }
|
||||
.playlist-track-handle { display: grid; place-items: center; flex-shrink: 0; width: 24px; height: 36px; margin-left: -4px; color: var(--color-foreground-subtlest); cursor: grab; touch-action: none; -webkit-touch-callout: none; }
|
||||
.playlist-tracks[data-dragging], .playlist-tracks[data-dragging] .playlist-track-handle { cursor: grabbing; }
|
||||
.playlist-tracks[data-dragging] .playlist-track:not([data-selected]):hover { background: transparent; }
|
||||
@media (hover: hover) {
|
||||
.playlist-track-handle { opacity: 0; transition: opacity 120ms ease; }
|
||||
.playlist-tracks:not([data-dragging]) .playlist-track:is(:hover, :focus-visible) .playlist-track-handle { opacity: 1; }
|
||||
}
|
||||
.playlist-drop-line { position: absolute; top: 0; left: 8px; right: 8px; height: 2px; margin-top: -1px; border-radius: 1px; background: var(--color-brand); pointer-events: none; visibility: hidden; }
|
||||
.playlist-drag-label { position: fixed; top: 0; left: 0; z-index: 40; max-width: 240px; padding: 4px 10px; overflow: hidden; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-popover); color: var(--color-foreground); box-shadow: 0 8px 24px var(--color-shadow); text-overflow: ellipsis; white-space: nowrap; pointer-events: none; visibility: hidden; }
|
||||
|
||||
.playlist-selection-bar { position: sticky; bottom: 16px; z-index: 5; display: flex; align-items: center; gap: 2px; width: fit-content; max-width: 100%; margin: 20px auto 0; padding: 6px 6px 6px 16px; border: 1px solid var(--color-border); border-radius: 12px; background: var(--color-popover); box-shadow: 0 12px 32px var(--color-shadow); animation: selection-bar-in 200ms cubic-bezier(.2, .8, .2, 1) both; }
|
||||
.playlist-selection-count { margin-right: 8px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.playlist-bar-button { display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 6px 10px; border: 0; border-radius: 8px; background: transparent; color: var(--color-foreground); white-space: nowrap; }
|
||||
.playlist-bar-button:hover { background: var(--color-hover); }
|
||||
.playlist-bar-button > svg { color: var(--color-foreground-subtle); }
|
||||
.playlist-bar-close { margin-left: 4px; color: var(--color-foreground-subtle); }
|
||||
:root[data-performance='low'] .playlist-selection-bar { animation-name: fade-in; }
|
||||
@keyframes selection-bar-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@container playlists (max-width: 720px) {
|
||||
.playlist-table { --playlist-columns: minmax(0, 1fr) 36px; }
|
||||
.playlist-table-head, .playlist-col-trackCount, .playlist-col-updatedAt, .playlist-col-createdAt { display: none; }
|
||||
.playlist-row-count { display: block; }
|
||||
}
|
||||
@container playlists (max-width: 560px) {
|
||||
.playlists-toolbar { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: 'heading create' 'controls controls'; align-items: center; gap: 16px 12px; margin-bottom: 20px; }
|
||||
.playlists-controls { justify-content: space-between; }
|
||||
.playlist-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 12px; }
|
||||
}
|
||||
@container my-playlist (max-width: 640px) {
|
||||
.playlist-bar-button { justify-content: center; width: 36px; padding: 0; }
|
||||
.playlist-bar-button > span { display: none; }
|
||||
}
|
||||
@container my-playlist (max-width: 560px) {
|
||||
.my-playlist-header { flex-direction: column; align-items: flex-start; gap: 16px; padding-bottom: 20px; }
|
||||
.playlist-cover-lg { width: 176px; }
|
||||
.my-playlist-name { font-size: var(--text-ui-xl); }
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
|
||||
export function saveToPlaylistLabel(title: string): string {
|
||||
return `把${bookTitle(title)}保存到歌单`
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import type { PlaylistSort } from './playlist-sort'
|
||||
import type { MyPlaylist, Track } from '@/core/realtime'
|
||||
import { ListPlus, Plus } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { IconButton, Modal, notificationStore, Skeleton } from '@/shared/ui'
|
||||
import { CreatePlaylistForm } from './create-playlist-form'
|
||||
import { FavoritesMark, PlaylistCover } from './playlist-cover'
|
||||
import { sortPlaylists } from './playlist-sort'
|
||||
import { playlistsStore, trackKey } from './playlists-store'
|
||||
import { saveToPlaylistLabel } from './save-label'
|
||||
import './playlists.css'
|
||||
|
||||
const byUpdate: PlaylistSort = { key: 'updatedAt', order: 'desc' }
|
||||
const none: ReadonlySet<string> = new Set()
|
||||
|
||||
// 保持打开时的先后:保存会更新歌单的更新时间,按实时的时间排,刚勾选的歌单会跳走。打开之后才出现的歌单排在最后。
|
||||
function arrange(playlists: readonly MyPlaylist[], order: readonly string[]): MyPlaylist[] {
|
||||
const rank = new Map(order.map((id, index) => [id, index]))
|
||||
const at = (playlist: MyPlaylist) => rank.get(playlist.id) ?? order.length
|
||||
return sortPlaylists(playlists, byUpdate).sort((a, b) => at(a) - at(b))
|
||||
}
|
||||
|
||||
function savedMessage(saved: readonly MyPlaylist[], skipped: number): string {
|
||||
const where = saved.length === 1 ? `“${saved[0].name}”` : ` ${saved.length} 个歌单`
|
||||
return `已保存到${where}${skipped > 0 ? `,其中 ${skipped} 首已经在里面` : ''}`
|
||||
}
|
||||
|
||||
// 一首歌:勾选立即生效。歌单里选中的多首:勾好目标歌单后一起保存,来源歌单不在列表里。
|
||||
type SaveToPlaylistProps = { onClose: () => void } & ({ track: Track, title: string } | { from: MyPlaylist, tracks: Track[] })
|
||||
|
||||
export function SaveToPlaylistDialog(props: SaveToPlaylistProps) {
|
||||
const { onClose } = props
|
||||
const track = 'track' in props ? props.track : null
|
||||
const from = 'from' in props ? props.from : null
|
||||
const account = useStore(profileStore, s => s.account)
|
||||
const playlists = useStore(playlistsStore, s => s.playlists)
|
||||
const failed = useStore(playlistsStore, s => s.failed)
|
||||
const included = useStore(playlistsStore, s => track ? s.memberships.get(trackKey(track)) : undefined) ?? none
|
||||
const [picked, setPicked] = useState<ReadonlySet<string>>(none)
|
||||
const [order, setOrder] = useState<string[] | null>(null)
|
||||
const [readFailed, setReadFailed] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
// 从新建页回到列表时要获得焦点的元素:取消时是“新建歌单”,建好之后是新歌单的勾选框,聚焦的同时把它滚进视野。
|
||||
const returnFocusRef = useRef<string | null>(null)
|
||||
// 打开时刷新歌单:歌曲数和其他设备上新建的歌单。
|
||||
useEffect(() => {
|
||||
if (!account)
|
||||
return
|
||||
let active = true
|
||||
void Promise.all([playlistsStore.load(), track ? playlistsStore.containing(track) : true]).then(([, read]) => {
|
||||
if (!active)
|
||||
return
|
||||
setReadFailed(!read)
|
||||
setOrder(sortPlaylists(playlistsStore.state.playlists ?? [], byUpdate).map(p => p.id))
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [account, track, attempt])
|
||||
useEffect(() => {
|
||||
if (creating || returnFocusRef.current === null)
|
||||
return
|
||||
listRef.current?.querySelector<HTMLElement>(returnFocusRef.current)?.focus()
|
||||
returnFocusRef.current = null
|
||||
}, [creating])
|
||||
|
||||
const rows = playlists && order ? arrange(playlists, order).filter(p => p.id !== from?.id) : null
|
||||
|
||||
const choose = (playlist: MyPlaylist, checked: boolean) => {
|
||||
if (track) {
|
||||
void playlistsStore.setMembership(playlist, track, checked)
|
||||
return
|
||||
}
|
||||
setPicked((current) => {
|
||||
const next = new Set(current)
|
||||
if (checked)
|
||||
next.add(playlist.id)
|
||||
else
|
||||
next.delete(playlist.id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const backToList = (focus: string) => {
|
||||
returnFocusRef.current = focus
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const save = async (source: MyPlaylist, tracks: Track[], targets: MyPlaylist[]) => {
|
||||
setSaving(true)
|
||||
const results = await Promise.all(targets.map(target => playlistsStore.copyItems(source, target, tracks)))
|
||||
const saved = targets.filter((_, index) => results[index] !== null)
|
||||
const skipped = results.reduce<number>((sum, added) => added === null ? sum : sum + tracks.length - added, 0)
|
||||
if (saved.length > 0)
|
||||
notificationStore.success(savedMessage(saved, skipped))
|
||||
onClose()
|
||||
}
|
||||
|
||||
let body
|
||||
if (!account) {
|
||||
body = <p className="text-ui-base muted">注册账号之后就能建自己的歌单。</p>
|
||||
}
|
||||
else if (readFailed || (playlists === null && failed)) {
|
||||
body = (
|
||||
<button
|
||||
type="button"
|
||||
className="button-secondary"
|
||||
onClick={() => {
|
||||
setReadFailed(false)
|
||||
setOrder(null)
|
||||
setAttempt(n => n + 1)
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
)
|
||||
}
|
||||
else if (rows === null) {
|
||||
body = <Skeleton rows={3} />
|
||||
}
|
||||
else if (creating) {
|
||||
body = (
|
||||
<CreatePlaylistForm
|
||||
onCreated={(playlist) => {
|
||||
choose(playlist, true)
|
||||
backToList(`input[value="${CSS.escape(playlist.id)}"]`)
|
||||
}}
|
||||
onCancel={() => backToList('.save-playlist-new')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
body = (
|
||||
<>
|
||||
<p className="save-playlist-subject text-ui-caption muted truncate">{'tracks' in props ? `已选 ${props.tracks.length} 首` : props.title}</p>
|
||||
<ul ref={listRef} className="save-playlist-list">
|
||||
{rows.map(playlist => (
|
||||
<li key={playlist.id}>
|
||||
<label className="save-playlist-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={playlist.id}
|
||||
checked={(track ? included : picked).has(playlist.id)}
|
||||
disabled={saving}
|
||||
onChange={event => choose(playlist, event.target.checked)}
|
||||
/>
|
||||
<PlaylistCover cover={playlist.coverUrl} favorites={playlist.favorites} size="sm" />
|
||||
<span className="save-playlist-name">
|
||||
<span className="text-ui-base truncate">{playlist.name}</span>
|
||||
{playlist.favorites && <FavoritesMark />}
|
||||
</span>
|
||||
<span className="text-ui-sm muted">{`${playlist.trackCount} 首`}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<button type="button" className="save-playlist-row save-playlist-new" disabled={saving} onClick={() => setCreating(true)}>
|
||||
<span className="save-playlist-new-tile"><Plus strokeWidth={1.7} aria-hidden="true" /></span>
|
||||
<span className="text-ui-base">新建歌单</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{'tracks' in props && (
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button-primary"
|
||||
disabled={saving || picked.size === 0}
|
||||
onClick={() => void save(props.from, props.tracks, rows.filter(p => picked.has(p.id)))}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <Modal title={creating ? '新建歌单' : '保存到歌单'} onClose={onClose}>{body}</Modal>
|
||||
}
|
||||
|
||||
export function SaveToPlaylistButton({ track, title, className = '' }: { track: Track, title: string, className?: string }) {
|
||||
// 记下打开时的歌:对话框开着时换了歌,勾选仍然作用于打开时的那首。
|
||||
const [target, setTarget] = useState<{ track: Track, title: string } | null>(null)
|
||||
return (
|
||||
<>
|
||||
<IconButton icon={ListPlus} className={className} label={saveToPlaylistLabel(title)} onClick={() => setTarget({ track, title })} />
|
||||
{target && <SaveToPlaylistDialog track={target.track} title={target.title} onClose={() => setTarget(null)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { ArrowDownToLine, ArrowUpToLine, ListChecks, ListPlus, ListX, Trash2, X } from 'lucide-react'
|
||||
import { IconButton } from '@/shared/ui'
|
||||
|
||||
// 窄屏只显示图标,按钮的名称要包含显示的文字。
|
||||
function BarButton({ icon: Icon, text, label, onClick }: { icon: LucideIcon, text: string, label: string, onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="playlist-bar-button" aria-label={label} onClick={onClick}>
|
||||
<Icon size={18} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectionBar({ count, allSelected, onSave, onTop, onBottom, onRemove, onToggleAll, onClose }: {
|
||||
count: number
|
||||
allSelected: boolean
|
||||
onSave: () => void
|
||||
onTop: () => void
|
||||
onBottom: () => void
|
||||
onRemove: () => void
|
||||
onToggleAll: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="playlist-selection-bar" role="toolbar" aria-label="选中的歌">
|
||||
<span className="playlist-selection-count text-ui-sm" aria-live="polite">{`已选 ${count} 首`}</span>
|
||||
<BarButton icon={ListPlus} text="保存到歌单" label="把选中的歌保存到歌单" onClick={onSave} />
|
||||
<BarButton icon={ArrowUpToLine} text="移到最前" label="把选中的歌移到最前" onClick={onTop} />
|
||||
<BarButton icon={ArrowDownToLine} text="移到最后" label="把选中的歌移到最后" onClick={onBottom} />
|
||||
<BarButton icon={Trash2} text="移出" label="把选中的歌移出这个歌单" onClick={onRemove} />
|
||||
<BarButton icon={allSelected ? ListX : ListChecks} text={allSelected ? '取消全选' : '全选'} label={allSelected ? '取消全选' : '全选'} onClick={onToggleAll} />
|
||||
<IconButton icon={X} className="playlist-bar-close" label="退出选择" onClick={onClose} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { dropAt, moveByOne, moveTracks } from './track-order'
|
||||
|
||||
const order = ['a', 'b', 'c', 'd', 'e']
|
||||
|
||||
// 与 internal/domain/playlist/order_test.go 的 TestMove 逐条对应。
|
||||
describe('moveTracks', () => {
|
||||
const cases: { name: string, moved: string[], before?: string, want?: string[], error?: string }[] = [
|
||||
{ name: '移到最前', moved: ['d'], before: 'a', want: ['d', 'a', 'b', 'c', 'e'] },
|
||||
{ name: '移到最后', moved: ['b'], want: ['a', 'c', 'd', 'e', 'b'] },
|
||||
{ name: '移到中间', moved: ['a'], before: 'd', want: ['b', 'c', 'a', 'd', 'e'] },
|
||||
{ name: '多首保持给定先后', moved: ['e', 'b'], before: 'd', want: ['a', 'c', 'e', 'b', 'd'] },
|
||||
{ name: '多首移到最后保持给定先后', moved: ['c', 'a'], want: ['b', 'd', 'e', 'c', 'a'] },
|
||||
{ name: '忽略不存在的歌', moved: ['x', 'c', 'y'], before: 'a', want: ['c', 'a', 'b', 'd', 'e'] },
|
||||
{ name: '重复的歌只算一次', moved: ['c', 'c', 'b'], want: ['a', 'd', 'e', 'c', 'b'] },
|
||||
{ name: 'before 紧跟在移动的歌之后', moved: ['b'], before: 'c', want: order },
|
||||
{ name: 'before 是被移动的歌', moved: ['b', 'c'], before: 'c', error: 'before 不能是要移动的歌' },
|
||||
{ name: 'before 是不存在的被移动的歌', moved: ['x'], before: 'x', error: 'before 不能是要移动的歌' },
|
||||
{ name: 'before 不存在', moved: ['b'], before: 'x', error: '歌单已经变了,刷新之后再试' },
|
||||
{ name: '空移动', moved: [], before: 'c', want: order },
|
||||
{ name: '移动的歌都不存在', moved: ['x'], before: 'y', want: order },
|
||||
]
|
||||
for (const { name, moved, before, want, error } of cases) {
|
||||
it(name, () => {
|
||||
const copy = [...order]
|
||||
if (error)
|
||||
expect(() => moveTracks(copy, moved, before)).toThrow(error)
|
||||
else
|
||||
expect(moveTracks(copy, moved, before)).toEqual(want)
|
||||
expect(copy).toEqual(order)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('dropAt', () => {
|
||||
it('插入位置之后第一首不移动的歌作为 before', () => {
|
||||
expect(dropAt(order, new Set(['d']), 1)).toEqual({ order: ['a', 'd', 'b', 'c', 'e'], moved: ['d'], before: 'b' })
|
||||
})
|
||||
|
||||
it('插入位置紧跟着要移动的歌时,跳过它们找 before', () => {
|
||||
expect(dropAt(order, new Set(['b', 'e']), 1)).toEqual({ order: ['a', 'b', 'e', 'c', 'd'], moved: ['b', 'e'], before: 'c' })
|
||||
})
|
||||
|
||||
it('放到最后时不传 before', () => {
|
||||
expect(dropAt(order, new Set(['a', 'c']), 5)).toEqual({ order: ['b', 'd', 'e', 'a', 'c'], moved: ['a', 'c'], before: undefined })
|
||||
})
|
||||
|
||||
it('多首按当前顺序一起移动', () => {
|
||||
expect(dropAt(order, new Set(['e', 'b']), 0)).toEqual({ order: ['b', 'e', 'a', 'c', 'd'], moved: ['b', 'e'], before: 'a' })
|
||||
})
|
||||
|
||||
it('拖回原位置不必发请求', () => {
|
||||
expect(dropAt(order, new Set(['c']), 2)).toBeNull()
|
||||
expect(dropAt(order, new Set(['c']), 3)).toBeNull()
|
||||
expect(dropAt(order, new Set(['b', 'c']), 2)).toBeNull()
|
||||
})
|
||||
|
||||
it('全选时什么也不做', () => {
|
||||
expect(dropAt(order, new Set(order), 0)).toBeNull()
|
||||
expect(dropAt(order, new Set(order), 5)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveByOne', () => {
|
||||
it('上移和下移一首', () => {
|
||||
expect(moveByOne(order, new Set(['c']), -1)).toEqual({ order: ['a', 'c', 'b', 'd', 'e'], moved: ['c'], before: 'b' })
|
||||
expect(moveByOne(order, new Set(['c']), 1)).toEqual({ order: ['a', 'b', 'd', 'c', 'e'], moved: ['c'], before: 'e' })
|
||||
})
|
||||
|
||||
it('下移到最后时不传 before', () => {
|
||||
expect(moveByOne(order, new Set(['d']), 1)).toEqual({ order: ['a', 'b', 'c', 'e', 'd'], moved: ['d'], before: undefined })
|
||||
})
|
||||
|
||||
it('相连的多首一起移动', () => {
|
||||
expect(moveByOne(order, new Set(['b', 'c']), -1)).toEqual({ order: ['b', 'c', 'a', 'd', 'e'], moved: ['b', 'c'], before: 'a' })
|
||||
expect(moveByOne(order, new Set(['b', 'c']), 1)).toEqual({ order: ['a', 'd', 'b', 'c', 'e'], moved: ['b', 'c'], before: 'e' })
|
||||
})
|
||||
|
||||
it('不相连的多首先收拢', () => {
|
||||
expect(moveByOne(order, new Set(['b', 'd']), -1)).toEqual({ order: ['b', 'd', 'a', 'c', 'e'], moved: ['b', 'd'], before: 'a' })
|
||||
expect(moveByOne(order, new Set(['b', 'd']), 1)).toEqual({ order: ['a', 'c', 'e', 'b', 'd'], moved: ['b', 'd'], before: undefined })
|
||||
})
|
||||
|
||||
it('已经在最前时上移只收拢', () => {
|
||||
expect(moveByOne(order, new Set(['a', 'c']), -1)).toEqual({ order: ['a', 'c', 'b', 'd', 'e'], moved: ['a', 'c'], before: 'b' })
|
||||
})
|
||||
|
||||
it('已经到头时不动', () => {
|
||||
expect(moveByOne(order, new Set(['a', 'b']), -1)).toBeNull()
|
||||
expect(moveByOne(order, new Set(['e']), 1)).toBeNull()
|
||||
expect(moveByOne(order, new Set(order), 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('要移动的歌都不在列表里时不动', () => {
|
||||
expect(moveByOne(order, new Set(['x']), -1)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
// 与后端的 playlist.Move 一致:order 是显示顺序,把 moved 按给出的先后放到 before 之前,没有 before 时放到最后。
|
||||
// moved 里不在 order 中的忽略,重复的只算一次;都不在 order 中时原样返回,此时不检查 before 是否还在。
|
||||
export function moveTracks<T>(order: readonly T[], moved: readonly T[], before?: T): T[] {
|
||||
if (before !== undefined && moved.includes(before))
|
||||
throw new Error('before 不能是要移动的歌')
|
||||
const present = new Set(order)
|
||||
const block = [...new Set(moved)].filter(item => present.has(item))
|
||||
if (block.length === 0)
|
||||
return [...order]
|
||||
const blocked = new Set(block)
|
||||
const rest = order.filter(item => !blocked.has(item))
|
||||
const at = before === undefined ? rest.length : rest.indexOf(before)
|
||||
if (at < 0)
|
||||
throw new Error('歌单已经变了,刷新之后再试')
|
||||
return [...rest.slice(0, at), ...block, ...rest.slice(at)]
|
||||
}
|
||||
|
||||
// 一次移动请求:把 moved 按 order 中的先后放到 before 之前,结果是 order。
|
||||
export interface MovePlan<T> {
|
||||
order: T[]
|
||||
moved: T[]
|
||||
before: T | undefined
|
||||
}
|
||||
|
||||
// 把 moved 收拢成一段,放在其余的歌中第 at 首之前;顺序不变时返回 null,不必发请求。
|
||||
function place<T>(order: readonly T[], moved: ReadonlySet<T>, at: number): MovePlan<T> | null {
|
||||
const block = order.filter(item => moved.has(item))
|
||||
const before = order.filter(item => !moved.has(item))[at]
|
||||
const next = moveTracks(order, block, before)
|
||||
if (next.every((item, index) => item === order[index]))
|
||||
return null
|
||||
return { order: next, moved: block, before }
|
||||
}
|
||||
|
||||
// index 是插入位置,取 0 到 order.length,表示放在 order[index] 之前。before 取插入位置之后第一首不移动的歌。
|
||||
export function dropAt<T>(order: readonly T[], moved: ReadonlySet<T>, index: number): MovePlan<T> | null {
|
||||
return place(order, moved, order.slice(0, index).filter(item => !moved.has(item)).length)
|
||||
}
|
||||
|
||||
// 上移时整段放到第一首要移动的歌前面那首之前,下移时放到最后一首要移动的歌后面那首之后。
|
||||
// 不相连的歌会先收拢到一起,与拖动多首时一致,所以一次请求就能完成。
|
||||
export function moveByOne<T>(order: readonly T[], moved: ReadonlySet<T>, delta: -1 | 1): MovePlan<T> | null {
|
||||
const indexes = order.flatMap((item, index) => moved.has(item) ? [index] : [])
|
||||
if (indexes.length === 0)
|
||||
return null
|
||||
const edge = delta < 0 ? indexes[0] : indexes[indexes.length - 1]
|
||||
const restBefore = edge - indexes.filter(index => index < edge).length
|
||||
const restCount = order.length - indexes.length
|
||||
return place(order, moved, Math.min(restCount, Math.max(0, restBefore + delta)))
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { Selection } from './track-selection'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { noSelection, retain, selectAll, selectRange, toggle } from './track-selection'
|
||||
|
||||
const order = ['a', 'b', 'c', 'd', 'e']
|
||||
|
||||
function keys(selection: Selection): string[] {
|
||||
return [...selection.keys].sort()
|
||||
}
|
||||
|
||||
describe('track-selection', () => {
|
||||
it('单击和 ⌘ 点击逐首切换,并把这首作为锚点', () => {
|
||||
const one = toggle(noSelection, 'b')
|
||||
expect(keys(one)).toEqual(['b'])
|
||||
expect(one.anchor).toBe('b')
|
||||
const two = toggle(one, 'd')
|
||||
expect(keys(two)).toEqual(['b', 'd'])
|
||||
const back = toggle(two, 'b')
|
||||
expect(keys(back)).toEqual(['d'])
|
||||
expect(back.anchor).toBe('b')
|
||||
})
|
||||
|
||||
it('shift 点击把锚点到这首的一段加入选择,锚点不变', () => {
|
||||
const selection = selectRange(toggle(toggle(noSelection, 'e'), 'b'), order, 'd')
|
||||
expect(keys(selection)).toEqual(['b', 'c', 'd', 'e'])
|
||||
expect(selection.anchor).toBe('b')
|
||||
})
|
||||
|
||||
it('shift 点击锚点之前的歌时向上选', () => {
|
||||
expect(keys(selectRange(toggle(noSelection, 'd'), order, 'b'))).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('没有锚点时 shift 点击只切换这首', () => {
|
||||
const selection = selectRange(noSelection, order, 'c')
|
||||
expect(keys(selection)).toEqual(['c'])
|
||||
expect(selection.anchor).toBe('c')
|
||||
})
|
||||
|
||||
it('锚点已经不在列表里时 shift 点击只切换这首', () => {
|
||||
expect(keys(selectRange(toggle(noSelection, 'x'), order, 'c'))).toEqual(['c', 'x'])
|
||||
})
|
||||
|
||||
it('全选和清空', () => {
|
||||
expect(keys(selectAll(order))).toEqual(order)
|
||||
expect(keys(noSelection)).toEqual([])
|
||||
})
|
||||
|
||||
it('列表变化后去掉不存在的歌和锚点', () => {
|
||||
const selection = retain(toggle(toggle(noSelection, 'a'), 'c'), ['a', 'b'])
|
||||
expect(keys(selection)).toEqual(['a'])
|
||||
expect(selection.anchor).toBeNull()
|
||||
})
|
||||
|
||||
it('列表变化不影响选择时返回原对象', () => {
|
||||
const selection = toggle(noSelection, 'c')
|
||||
expect(retain(selection, order)).toBe(selection)
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
export interface Selection {
|
||||
keys: ReadonlySet<string>
|
||||
// Shift 点击从这里选起:最后一次逐首切换的那首。
|
||||
anchor: string | null
|
||||
}
|
||||
|
||||
export const noSelection: Selection = { keys: new Set(), anchor: null }
|
||||
|
||||
export function toggle(selection: Selection, key: string): Selection {
|
||||
const keys = new Set(selection.keys)
|
||||
if (!keys.delete(key))
|
||||
keys.add(key)
|
||||
return { keys, anchor: key }
|
||||
}
|
||||
|
||||
// 把锚点到 key 的一段加入选择;没有锚点,或者锚点已经不在列表里时,只切换 key。
|
||||
export function selectRange(selection: Selection, order: readonly string[], key: string): Selection {
|
||||
const from = selection.anchor === null ? -1 : order.indexOf(selection.anchor)
|
||||
const to = order.indexOf(key)
|
||||
if (from < 0 || to < 0)
|
||||
return toggle(selection, key)
|
||||
const range = order.slice(Math.min(from, to), Math.max(from, to) + 1)
|
||||
return { keys: new Set([...selection.keys, ...range]), anchor: selection.anchor }
|
||||
}
|
||||
|
||||
export function selectAll(order: readonly string[]): Selection {
|
||||
return { keys: new Set(order), anchor: null }
|
||||
}
|
||||
|
||||
// 列表重新读取之后去掉已经不在里面的歌;没有变化时返回原对象。
|
||||
export function retain(selection: Selection, order: readonly string[]): Selection {
|
||||
const present = new Set(order)
|
||||
const keys = [...selection.keys].filter(key => present.has(key))
|
||||
const anchor = selection.anchor !== null && present.has(selection.anchor) ? selection.anchor : null
|
||||
if (keys.length === selection.keys.size && anchor === selection.anchor)
|
||||
return selection
|
||||
return { keys: new Set(keys), anchor }
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react'
|
||||
import type { MovePlan } from './track-order'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { dropAt } from './track-order'
|
||||
|
||||
// 按下之后移动超过这个距离才算拖动,否则是点击。
|
||||
const dragThreshold = 4
|
||||
const scrollZone = 56
|
||||
const maxScrollStep = 18
|
||||
|
||||
function scrollParent(element: HTMLElement): HTMLElement {
|
||||
for (let node = element.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node)
|
||||
if (overflowY === 'auto' || overflowY === 'scroll')
|
||||
return node
|
||||
}
|
||||
return document.documentElement
|
||||
}
|
||||
|
||||
// 插入位置:指针在第几行的上半部分之上,取 0 到行数。
|
||||
function insertionIndex(rows: readonly HTMLElement[], y: number): number {
|
||||
let low = 0
|
||||
let high = rows.length
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1
|
||||
const rect = rows[middle].getBoundingClientRect()
|
||||
if (y < rect.top + rect.height / 2)
|
||||
high = middle
|
||||
else
|
||||
low = middle + 1
|
||||
}
|
||||
return low
|
||||
}
|
||||
|
||||
// 拖动结束时,按下和松开的位置不在同一行,浏览器仍会派发 click:吞掉它,免得切换选中。
|
||||
function swallowNextClick(): void {
|
||||
const swallow = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
window.addEventListener('click', swallow, { capture: true, once: true })
|
||||
setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 0)
|
||||
}
|
||||
|
||||
// 鼠标按住行的任意位置都能拖,触屏只能拖把手,行的其余部分留给滚动。
|
||||
// 插入线和指针旁的标签每一帧直接改样式,不经过 React,拖动时整张列表不会重新渲染。
|
||||
export function useTrackDrag({ listRef, lineRef, labelRef, keys, selected, onDrop }: {
|
||||
listRef: RefObject<HTMLElement | null>
|
||||
lineRef: RefObject<HTMLElement | null>
|
||||
labelRef: RefObject<HTMLElement | null>
|
||||
keys: readonly string[]
|
||||
selected: ReadonlySet<string>
|
||||
onDrop: (plan: MovePlan<string> | null) => void
|
||||
}) {
|
||||
const [dragging, setDragging] = useState<ReadonlySet<string> | null>(null)
|
||||
const latestRef = useRef({ keys, selected, onDrop })
|
||||
useLayoutEffect(() => {
|
||||
latestRef.current = { keys, selected, onDrop }
|
||||
})
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
useEffect(() => () => stopRef.current?.(), [])
|
||||
|
||||
const onPointerDown = (event: ReactPointerEvent<HTMLElement>, key: string) => {
|
||||
const target = event.target as Element
|
||||
if (event.button !== 0 || stopRef.current || target.closest('button, a'))
|
||||
return
|
||||
if (event.pointerType !== 'mouse' && !target.closest('[data-drag-handle]'))
|
||||
return
|
||||
const list = listRef.current
|
||||
if (!list)
|
||||
return
|
||||
const { pointerId } = event
|
||||
const scroller = scrollParent(list)
|
||||
const start = { x: event.clientX, y: event.clientY }
|
||||
const pointer = { ...start }
|
||||
let moved: ReadonlySet<string> | null = null
|
||||
let frame = 0
|
||||
|
||||
const rows = () => [...list.querySelectorAll<HTMLElement>('[data-key]')]
|
||||
|
||||
const autoScroll = () => {
|
||||
const rect = scroller === document.documentElement ? new DOMRect(0, 0, innerWidth, innerHeight) : scroller.getBoundingClientRect()
|
||||
const top = Math.max(rect.top, 0)
|
||||
const bottom = Math.min(rect.bottom, innerHeight)
|
||||
const into = pointer.y < top + scrollZone ? pointer.y - top - scrollZone : pointer.y > bottom - scrollZone ? pointer.y - bottom + scrollZone : 0
|
||||
if (into !== 0)
|
||||
scroller.scrollTop += Math.max(-1, Math.min(1, into / scrollZone)) * maxScrollStep
|
||||
}
|
||||
|
||||
const paint = () => {
|
||||
autoScroll()
|
||||
const all = rows()
|
||||
const index = insertionIndex(all, pointer.y)
|
||||
const line = lineRef.current
|
||||
if (line && all.length > 0) {
|
||||
const plan = moved && dropAt(latestRef.current.keys, moved, index)
|
||||
const edge = index < all.length ? all[index].getBoundingClientRect().top : all[all.length - 1].getBoundingClientRect().bottom
|
||||
line.style.transform = `translateY(${edge - list.getBoundingClientRect().top}px)`
|
||||
line.style.visibility = plan ? 'visible' : 'hidden'
|
||||
}
|
||||
const label = labelRef.current
|
||||
if (label) {
|
||||
label.style.transform = `translate(${pointer.x + 14}px, ${pointer.y + 12}px)`
|
||||
label.style.visibility = 'visible'
|
||||
}
|
||||
frame = requestAnimationFrame(paint)
|
||||
}
|
||||
|
||||
function onMove(move: PointerEvent) {
|
||||
if (move.pointerId !== pointerId)
|
||||
return
|
||||
pointer.x = move.clientX
|
||||
pointer.y = move.clientY
|
||||
if (moved || Math.hypot(pointer.x - start.x, pointer.y - start.y) <= dragThreshold)
|
||||
return
|
||||
const { selected } = latestRef.current
|
||||
moved = selected.has(key) ? selected : new Set([key])
|
||||
setDragging(moved)
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
frame = requestAnimationFrame(paint)
|
||||
}
|
||||
|
||||
function endVisuals() {
|
||||
cancelAnimationFrame(frame)
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
setDragging(null)
|
||||
}
|
||||
|
||||
function onUp(up: PointerEvent) {
|
||||
if (up.pointerId !== pointerId)
|
||||
return
|
||||
const dropped = moved
|
||||
stop()
|
||||
if (!dropped)
|
||||
return
|
||||
swallowNextClick()
|
||||
latestRef.current.onDrop(dropAt(latestRef.current.keys, dropped, insertionIndex(rows(), up.clientY)))
|
||||
}
|
||||
|
||||
function onCancel(cancel: PointerEvent) {
|
||||
if (cancel.pointerId === pointerId)
|
||||
stop()
|
||||
}
|
||||
|
||||
// 按 Esc 取消之后指针仍然按着,松开时的 click 同样要吞掉。
|
||||
function onKey(keyboard: KeyboardEvent) {
|
||||
if (keyboard.key !== 'Escape')
|
||||
return
|
||||
keyboard.preventDefault()
|
||||
keyboard.stopPropagation()
|
||||
endVisuals()
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('pointerup', onUp)
|
||||
document.addEventListener('pointerup', onCancelledUp)
|
||||
}
|
||||
|
||||
function onCancelledUp(up: PointerEvent) {
|
||||
if (up.pointerId !== pointerId)
|
||||
return
|
||||
stop()
|
||||
swallowNextClick()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
endVisuals()
|
||||
document.removeEventListener('pointermove', onMove)
|
||||
document.removeEventListener('pointerup', onUp)
|
||||
document.removeEventListener('pointerup', onCancelledUp)
|
||||
document.removeEventListener('pointercancel', onCancel)
|
||||
stopRef.current = null
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', onUp)
|
||||
document.addEventListener('pointercancel', onCancel)
|
||||
stopRef.current = stop
|
||||
}
|
||||
|
||||
return { dragging, onPointerDown }
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadViewPreference, saveViewPreference } from './view-preference'
|
||||
|
||||
const key = 'alisten.playlists-view'
|
||||
let items: Map<string, string>
|
||||
|
||||
beforeEach(() => {
|
||||
items = new Map()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (name: string) => items.get(name) ?? null,
|
||||
setItem: (name: string, value: string) => items.set(name, value),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const fallback = { view: 'grid', sort: { key: 'updatedAt', order: 'desc' } }
|
||||
|
||||
describe('视图偏好', () => {
|
||||
it('没有保存过时用网格视图,按更新时间、最新在前', () => {
|
||||
expect(loadViewPreference()).toEqual(fallback)
|
||||
})
|
||||
|
||||
it('保存之后读到同样的偏好', () => {
|
||||
const preference = { view: 'list', sort: { key: 'name', order: 'asc' } } as const
|
||||
saveViewPreference(preference)
|
||||
expect(loadViewPreference()).toEqual(preference)
|
||||
})
|
||||
|
||||
it('不是 JSON 时回退默认', () => {
|
||||
items.set(key, '{')
|
||||
expect(loadViewPreference()).toEqual(fallback)
|
||||
items.set(key, 'null')
|
||||
expect(loadViewPreference()).toEqual(fallback)
|
||||
})
|
||||
|
||||
it('无效的字段各自回退默认', () => {
|
||||
items.set(key, JSON.stringify({ view: 'list', sort: { key: 'size', order: 'asc' } }))
|
||||
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
|
||||
items.set(key, JSON.stringify({ view: 'columns', sort: { key: 'trackCount', order: 'asc' } }))
|
||||
expect(loadViewPreference()).toEqual({ view: 'grid', sort: { key: 'trackCount', order: 'asc' } })
|
||||
items.set(key, JSON.stringify({ view: 'list', sort: { key: 'name', order: 'up' } }))
|
||||
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
|
||||
items.set(key, JSON.stringify({ view: 'list', sort: 'name' }))
|
||||
expect(loadViewPreference()).toEqual({ view: 'list', sort: fallback.sort })
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { PlaylistSort } from './playlist-sort'
|
||||
import { sortKeys, sortOrders } from './playlist-sort'
|
||||
|
||||
export type PlaylistView = 'list' | 'grid'
|
||||
|
||||
export interface ViewPreference {
|
||||
view: PlaylistView
|
||||
sort: PlaylistSort
|
||||
}
|
||||
|
||||
const preferenceKey = 'alisten.playlists-view'
|
||||
const views: PlaylistView[] = ['list', 'grid']
|
||||
const defaultPreference: ViewPreference = { view: 'grid', sort: { key: 'updatedAt', order: 'desc' } }
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function readStored(): Record<string, unknown> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(preferenceKey) ?? 'null')
|
||||
return isObject(value) ? value : {}
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadViewPreference(): ViewPreference {
|
||||
const stored = readStored()
|
||||
const sort = isObject(stored.sort) ? stored.sort : {}
|
||||
const key = sortKeys.find(k => k === sort.key)
|
||||
const order = sortOrders.find(o => o === sort.order)
|
||||
return {
|
||||
view: views.find(v => v === stored.view) ?? defaultPreference.view,
|
||||
sort: key && order ? { key, order } : defaultPreference.sort,
|
||||
}
|
||||
}
|
||||
|
||||
export function saveViewPreference(preference: ViewPreference): void {
|
||||
localStorage.setItem(preferenceKey, JSON.stringify(preference))
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { Entry, PlayMode, Track } from '@/core/realtime'
|
||||
import type { Entry, PlayMode } from '@/core/realtime'
|
||||
import type { SongItem } from '@/shared/ui'
|
||||
import { Heart, ListMusic, ListPlus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Heart, ListMusic, Trash2 } from 'lucide-react'
|
||||
import { roomStore } from '@/core/room'
|
||||
import { SaveToPlaylistDialog, saveToPlaylistLabel } from '@/features/playlists'
|
||||
import { bookTitle } from '@/shared/lib/book-title'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { EmptyState, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
|
||||
@@ -49,7 +47,6 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
|
||||
const items = useStore(roomStore, s => s.room?.items ?? noEntries)
|
||||
const playMode = useStore(roomStore, s => s.room?.playMode ?? 'sequential')
|
||||
const connected = useStore(roomStore, s => s.status === 'open')
|
||||
const [saving, setSaving] = useState<{ track: Track, title: string } | null>(null)
|
||||
return (
|
||||
<section className={compact ? 'queue-panel' : 'page-content queue-page'} aria-label="播放列表">
|
||||
<div className="section-heading">
|
||||
@@ -76,14 +73,10 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
|
||||
? (
|
||||
<SongList
|
||||
songs={items.map(songItem)}
|
||||
actions={(song, index) => {
|
||||
const { track } = items[index]
|
||||
return [
|
||||
{ icon: ListPlus, label: saveToPlaylistLabel(song.title), secondary: true, run: async () => setSaving({ track, title: song.title }) },
|
||||
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => likeEntry(song.key, song.title) },
|
||||
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => removeEntry(song.key, song.title) },
|
||||
]
|
||||
}}
|
||||
actions={song => [
|
||||
{ icon: Heart, label: `点赞${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => likeEntry(song.key, song.title) },
|
||||
{ icon: Trash2, label: `移除${bookTitle(song.title)}`, disabled: !connected, secondary: true, run: () => removeEntry(song.key, song.title) },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
: !pending && (
|
||||
@@ -91,7 +84,6 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
|
||||
每个人都可以点歌,一起决定下一首。
|
||||
</EmptyState>
|
||||
)}
|
||||
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { profileStore, switchIdentity } from './profile-store'
|
||||
|
||||
type Dialog = 'register' | 'login' | 'password' | null
|
||||
|
||||
const credentialTexts = { INVALID_ARGUMENT: '用户名要 3–32 位小写字母、数字、_ 或 -,密码要 8–128 位。' }
|
||||
const credentialTexts = { INVALID_ARGUMENT: '用户名要 3 到 32 位,只能用小写字母、数字、下划线和连字符;密码要 8 到 128 位。' }
|
||||
|
||||
// 对话框渲染在 portal 中,React 的事件仍会冒泡到设置页外层的表单,提交时要拦住,否则会顺带保存整页设置。
|
||||
function submitted(event: FormEvent, run: () => Promise<void>): void {
|
||||
@@ -17,7 +17,7 @@ function submitted(event: FormEvent, run: () => Promise<void>): void {
|
||||
void run()
|
||||
}
|
||||
|
||||
export function AccountRow() {
|
||||
export function AccountSection() {
|
||||
const member = useStore(profileStore, s => s.member)
|
||||
const account = useStore(profileStore, s => s.account)
|
||||
const failed = useStore(profileStore, s => s.failed)
|
||||
@@ -50,39 +50,58 @@ export function AccountRow() {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
const logoutOthers = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.logoutOtherSessions()
|
||||
notificationStore.success('其他设备已退出登录')
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
}
|
||||
finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
const description = !member
|
||||
? (failed ? '暂时无法获取你的资料。' : '正在获取你的资料…')
|
||||
: account
|
||||
? '在其他设备上登录这个账号,昵称和头像都一样。'
|
||||
? '在其他设备上用这个用户名登录,就是同一个身份。'
|
||||
: registrationOpen
|
||||
? '注册后可以在其他设备上登录,昵称和头像会保留。'
|
||||
: '已有账号的话,登录即可。'
|
||||
? '注册之后可以在其他设备上登录同一个身份,昵称和头像都会保留。'
|
||||
: '在其他设备上注册过账号的话,登录之后就是同一个身份。'
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-copy">
|
||||
<p className="settings-row-title text-ui-base">{account ? account.username : '游客'}</p>
|
||||
<p className="text-ui-caption muted">{description}</p>
|
||||
</div>
|
||||
{member && (
|
||||
<div className="settings-row-control settings-account-actions">
|
||||
{account
|
||||
? (
|
||||
<>
|
||||
<button type="button" className="button-secondary" onClick={() => setDialog('password')}>修改密码</button>
|
||||
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logout()}>退出登录</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{registrationOpen && <button type="button" className="button-secondary" onClick={() => setDialog('register')}>注册</button>}
|
||||
<button type="button" className="button-secondary" onClick={() => setDialog('login')}>登录</button>
|
||||
</>
|
||||
)}
|
||||
<section className="settings-section" aria-labelledby="settings-account-title">
|
||||
<h2 id="settings-account-title" className="text-ui-lg">账号</h2>
|
||||
<div className="settings-group">
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-copy">
|
||||
<p className="settings-row-title text-ui-base">{account ? account.username : '游客'}</p>
|
||||
<p className="text-ui-caption muted">{description}</p>
|
||||
</div>
|
||||
{member && (
|
||||
<div className="settings-row-control settings-account-actions">
|
||||
{account
|
||||
? (
|
||||
<>
|
||||
<button type="button" className="button-secondary" onClick={() => setDialog('password')}>修改密码</button>
|
||||
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logoutOthers()}>退出其他设备</button>
|
||||
<button type="button" className="button-secondary" disabled={busy} onClick={() => void logout()}>退出登录</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{registrationOpen && <button type="button" className="button-secondary" onClick={() => setDialog('register')}>注册</button>}
|
||||
<button type="button" className="button-secondary" onClick={() => setDialog('login')}>登录</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(dialog === 'register' || dialog === 'login') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
|
||||
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,9 +115,9 @@ function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login', onCl
|
||||
try {
|
||||
if (register) {
|
||||
const { account } = await api.register({ username, password })
|
||||
profileStore.setAccount(account)
|
||||
notificationStore.success('已注册')
|
||||
onClose()
|
||||
void profileStore.registered(account)
|
||||
}
|
||||
else {
|
||||
const { memberToken } = await api.login({ username, password })
|
||||
@@ -123,8 +142,8 @@ function CredentialsDialog({ mode, onClose }: { mode: 'register' | 'login', onCl
|
||||
</label>
|
||||
<p className="text-ui-caption muted">
|
||||
{register
|
||||
? '用户名 3–32 位,可用小写字母、数字、_ 和 -;密码至少 8 位。忘记密码需联系管理员。'
|
||||
: '登录后页面会重新加载。'}
|
||||
? '用户名 3 到 32 位,可以用小写字母、数字、下划线和连字符;密码至少 8 位。忘记密码时只能请管理员重置。'
|
||||
: '登录之后,这台设备上现在的游客身份不再使用,页面会重新加载。'}
|
||||
</p>
|
||||
<button type="submit" className="button-primary" disabled={busy}>
|
||||
{busy ? (register ? '正在注册…' : '正在登录…') : (register ? '注册' : '登录')}
|
||||
@@ -161,7 +180,7 @@ function PasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
新密码
|
||||
<input type="password" required autoComplete="new-password" minLength={8} maxLength={128} value={newPassword} onChange={event => setNewPassword(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-ui-caption muted">修改后,其他设备需要重新登录。</p>
|
||||
<p className="text-ui-caption muted">修改之后,其他设备上的登录都会失效,这台设备不受影响。</p>
|
||||
<button type="submit" className="button-primary" disabled={busy}>{busy ? '正在修改…' : '修改密码'}</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -32,7 +32,6 @@ const renewed = { id: 'm2', name: '游客-1a2b', avatarHash: '' }
|
||||
let local: FakeStorage
|
||||
let reload: ReturnType<typeof vi.fn>
|
||||
let me: unknown
|
||||
let updates: unknown[]
|
||||
|
||||
async function load() {
|
||||
const [{ profileStore, switchIdentity }, { identityEvents }] = await Promise.all([import('./profile-store'), import('@/core/api')])
|
||||
@@ -46,18 +45,12 @@ beforeEach(() => {
|
||||
local = new FakeStorage()
|
||||
reload = vi.fn()
|
||||
me = { member }
|
||||
updates = []
|
||||
vi.stubGlobal('localStorage', local)
|
||||
vi.stubGlobal('sessionStorage', new FakeStorage())
|
||||
vi.stubGlobal('window', { location: { reload } })
|
||||
vi.stubGlobal('fetch', (path: string, init: RequestInit) => {
|
||||
const request = `${init.method} ${path}`
|
||||
if (request === 'PATCH /api/me') {
|
||||
updates.push(JSON.parse(String(init.body)))
|
||||
return Promise.resolve(new Response(JSON.stringify({ member }), { status: 200 }))
|
||||
}
|
||||
if (request !== 'GET /api/me')
|
||||
throw new Error(`没有预期的请求 ${request}`)
|
||||
if (`${init.method} ${path}` !== 'GET /api/me')
|
||||
throw new Error(`没有预期的请求 ${init.method} ${path}`)
|
||||
return Promise.resolve(new Response(JSON.stringify(me), { status: 200 }))
|
||||
})
|
||||
})
|
||||
@@ -113,7 +106,7 @@ describe('账号状态', () => {
|
||||
|
||||
it('游客没有账号', async () => {
|
||||
const { profileStore } = await load()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
profileStore.setAccount({ username: 'alice' })
|
||||
await profileStore.load()
|
||||
expect(profileStore.state).toMatchObject({ member, account: null })
|
||||
})
|
||||
@@ -121,88 +114,19 @@ describe('账号状态', () => {
|
||||
it('注册之后设置账号', async () => {
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
profileStore.setAccount({ username: 'alice' })
|
||||
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } })
|
||||
})
|
||||
|
||||
it('身份被拒绝或更换之后没有账号', async () => {
|
||||
const { profileStore, identityEvents } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
profileStore.setAccount({ username: 'alice' })
|
||||
identityEvents.emit({ kind: 'rejected' })
|
||||
expect(profileStore.state).toMatchObject({ member: null, account: null })
|
||||
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
profileStore.setAccount({ username: 'alice' })
|
||||
identityEvents.emit({ kind: 'renewed', member: renewed })
|
||||
expect(profileStore.state).toMatchObject({ member: renewed, account: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('邮箱', () => {
|
||||
beforeEach(() => {
|
||||
local.setItem(memberTokenKey, 'good')
|
||||
})
|
||||
|
||||
it('已注册时取自账号,不读本机', async () => {
|
||||
local.setItem(emailKey, 'local@example.com')
|
||||
me = { member, account: { username: 'alice', email: 'Alice@Example.COM' } }
|
||||
const { profileStore } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.load()
|
||||
expect(profileStore.state.email).toBe('Alice@Example.COM')
|
||||
|
||||
me = { member, account: { username: 'alice' } }
|
||||
await profileStore.load()
|
||||
expect(profileStore.state.email).toBe('')
|
||||
})
|
||||
|
||||
it('游客取自本机', async () => {
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
const { profileStore } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.load()
|
||||
expect(profileStore.state.email).toBe('guest@example.com')
|
||||
})
|
||||
|
||||
it('已注册时保存到账号,不写本机', async () => {
|
||||
me = { member, account: { username: 'alice' } }
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
|
||||
expect(await profileStore.save({ email: ' Alice@Example.COM ' })).toBe(true)
|
||||
expect(updates).toEqual([{ email: ' Alice@Example.COM ' }])
|
||||
expect(profileStore.state).toMatchObject({ email: 'Alice@Example.COM', account: { username: 'alice', email: 'Alice@Example.COM' } })
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
|
||||
expect(await profileStore.save({ email: '' })).toBe(true)
|
||||
expect(profileStore.state.email).toBe('')
|
||||
expect(profileStore.state.account).toEqual({ username: 'alice' })
|
||||
})
|
||||
|
||||
it('游客保存到本机', async () => {
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
expect(await profileStore.save({ email: 'guest@example.com' })).toBe(true)
|
||||
expect(local.getItem(emailKey)).toBe('guest@example.com')
|
||||
expect(profileStore.state.email).toBe('guest@example.com')
|
||||
})
|
||||
|
||||
it('注册之后把本机的邮箱存进账号,并删除本机的副本', async () => {
|
||||
local.setItem(emailKey, 'guest@example.com')
|
||||
const { profileStore } = await load()
|
||||
profileStore.start()
|
||||
await profileStore.load()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
|
||||
expect(updates).toEqual([{ email: 'guest@example.com' }])
|
||||
expect(local.getItem(emailKey)).toBeNull()
|
||||
expect(profileStore.state).toMatchObject({ email: 'guest@example.com', account: { username: 'alice', email: 'guest@example.com' } })
|
||||
})
|
||||
|
||||
it('注册时本机没有邮箱就不发请求', async () => {
|
||||
const { profileStore } = await load()
|
||||
await profileStore.load()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
expect(updates).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { errorMessage } from '@/core/errors'
|
||||
import { Store } from '@/shared/lib/store'
|
||||
import { notificationStore } from '@/shared/ui'
|
||||
|
||||
// 游客的邮箱只保存在本机,服务端只保存它的哈希;已注册账号的邮箱保存在账号里。
|
||||
// 邮箱只保存在本机,用来在设置页回显;服务端只保存它的哈希。
|
||||
const emailKey = 'alisten.email'
|
||||
|
||||
class ProfileStore extends Store<{ member: Member | null, account: Account | null, email: string, failed: boolean }> {
|
||||
@@ -27,33 +27,22 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
return this.#loading
|
||||
}
|
||||
|
||||
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册。
|
||||
async registered(account: Account): Promise<void> {
|
||||
setAccount(account: Account): void {
|
||||
this.setState({ account })
|
||||
const email = localStorage.getItem(emailKey)
|
||||
if (email && await this.save({ email }))
|
||||
localStorage.removeItem(emailKey)
|
||||
}
|
||||
|
||||
// 返回 false 时,失败原因已经通过通知显示。
|
||||
async save(changes: { name?: string, email?: string }): Promise<boolean> {
|
||||
try {
|
||||
const { member } = await api.updateMe(changes)
|
||||
const { account } = this.state
|
||||
if (account) {
|
||||
const email = changes.email?.trim() ?? this.state.email
|
||||
this.setState({ member, email, account: { username: account.username, ...(email && { email }) }, failed: false })
|
||||
}
|
||||
else {
|
||||
const email = changes.email ?? this.state.email
|
||||
localStorage.setItem(emailKey, email)
|
||||
this.setState({ member, email, failed: false })
|
||||
}
|
||||
const email = changes.email ?? this.state.email
|
||||
localStorage.setItem(emailKey, email)
|
||||
this.setState({ member, email, failed: false })
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
// 客户端不能按服务端的 message 区分是哪个字段不合格。
|
||||
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
|
||||
// 输入框限制了昵称和邮箱的长度,INVALID_ARGUMENT 只剩去掉首尾空白之后为空的昵称。
|
||||
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空,也不能只有空格。' }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -62,7 +51,7 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
|
||||
this.setState({ failed: false })
|
||||
try {
|
||||
const { member, account } = await api.getMe()
|
||||
this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', failed: false })
|
||||
this.setState({ member, account: account ?? null, failed: false })
|
||||
}
|
||||
catch (error) {
|
||||
// 成员 token 被拒绝时身份已经更换,新的成员由身份事件设置。
|
||||
|
||||
@@ -7,7 +7,7 @@ import { historyStore, searchLimits } from '@/features/history'
|
||||
import { reminderStore, requestNotificationPermission, systemNotificationsSupported, volumeStore } from '@/features/playback'
|
||||
import { useStore } from '@/shared/lib/use-store'
|
||||
import { notificationStore, Select } from '@/shared/ui'
|
||||
import { AccountRow } from './account-section'
|
||||
import { AccountSection } from './account-section'
|
||||
import { appearanceStore, fontSizes, themes } from './appearance-store'
|
||||
import { performanceLevels, performanceStore } from './performance-store'
|
||||
import { profileStore } from './profile-store'
|
||||
@@ -69,7 +69,6 @@ export function SettingsPage() {
|
||||
const headingRef = useRef<HTMLHeadingElement>(null)
|
||||
const member = useStore(profileStore, s => s.member)
|
||||
const email = useStore(profileStore, s => s.email)
|
||||
const account = useStore(profileStore, s => s.account)
|
||||
const profileFailed = useStore(profileStore, s => s.failed)
|
||||
const theme = useStore(appearanceStore, s => s.theme)
|
||||
const fontSize = useStore(appearanceStore, s => s.fontSize)
|
||||
@@ -179,16 +178,16 @@ export function SettingsPage() {
|
||||
<div className="settings-page-body">
|
||||
<fieldset className="settings-fields" disabled={saving}>
|
||||
<div className="settings-content">
|
||||
<section className="settings-section" aria-labelledby="settings-account-title">
|
||||
<h2 id="settings-account-title" className="text-ui-lg">账号</h2>
|
||||
<AccountSection />
|
||||
<section className="settings-section" aria-labelledby="settings-profile-title">
|
||||
<h2 id="settings-profile-title" className="text-ui-lg">个人资料</h2>
|
||||
<div className="settings-group">
|
||||
<AccountRow />
|
||||
<SettingRow id="settings-name" title="昵称" description={member ? '房间里的其他人看到的名字。' : profileFailed ? '暂时无法获取你的资料。' : '正在获取你的资料…'} field>
|
||||
{!member && profileFailed
|
||||
? <button type="button" className="button-secondary" aria-describedby="settings-name-description" onClick={loadProfile}>重试</button>
|
||||
: <input id="settings-name" aria-describedby="settings-name-description" value={draft.name} onChange={event => update('name', event.target.value)} disabled={!member} required maxLength={80} autoComplete="nickname" />}
|
||||
</SettingRow>
|
||||
<SettingRow id="settings-email" title="邮箱" description={account ? '保存在账号里,用来显示头像。留空就不显示。' : '只保存在这台设备上,用来显示头像。留空就不显示。'} field>
|
||||
<SettingRow id="settings-email" title="邮箱" description="只用来显示 Gravatar 头像,访问不到 Gravatar 时改用镜像 WeAvatar 或 Cravatar;服务器不会保存邮箱地址,留空就不显示头像。" field>
|
||||
<input id="settings-email" aria-describedby="settings-email-description" type="email" value={draft.email} onChange={event => update('email', event.target.value)} disabled={!member} maxLength={320} autoComplete="email" placeholder="name@example.com" />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
--color-toast: #303034;
|
||||
--color-overlay: rgb(0 0 0 / 55%);
|
||||
--color-shadow: rgb(0 0 0 / 24%);
|
||||
--glow-opacity: .8;
|
||||
color-scheme: dark;
|
||||
font-synthesis: none;
|
||||
}
|
||||
@@ -88,7 +87,6 @@
|
||||
--color-success: #287144;
|
||||
--color-toast: #fff;
|
||||
--color-shadow: rgb(30 30 45 / 12%);
|
||||
--glow-opacity: .5;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
@@ -123,7 +121,6 @@
|
||||
--color-success: #287144;
|
||||
--color-toast: #fff;
|
||||
--color-shadow: rgb(30 30 45 / 12%);
|
||||
--glow-opacity: .5;
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export default defineConfig({
|
||||
workbox: {
|
||||
// 与 nginx 一致:只有页面路径返回 index.html,/api、/ws 和 /media 照常走网络。
|
||||
// Workbox 拿路径加查询参数来匹配,nginx 只看路径,所以这里的规则末尾允许查询参数。
|
||||
navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/],
|
||||
navigateFallbackAllowlist: [/^\/(?:admin|settings|history)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/],
|
||||
importScripts: ['notification-click.js'],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user