Compare commits

..
18 Commits
Author SHA1 Message Date
DiheChen 5ca315f226 feat: select several playlist songs to remove, save or reorder them and drag songs into place
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s
2026-11-09 02:14:21 +08:00
DiheChen fb0820fae1 feat: browse my playlists as a sortable list or a glowing cover grid and open each one 2026-11-09 02:09:12 +08:00
DiheChen 0c6007e7c4 feat: save songs to my playlists from the player, queue and history 2026-11-09 02:01:58 +08:00
DiheChen d6a77bb04b feat: track member playlists in the web client 2026-11-09 01:56:06 +08:00
DiheChen 24abca6dc7 feat: tell which of my playlists contain a song 2026-11-09 01:47:45 +08:00
DiheChen 5c34450ccf feat: remove, copy and reorder several playlist songs at once 2026-11-09 01:43:07 +08:00
DiheChen d9a97b6331 feat: expose member playlists over HTTP 2026-11-09 01:36:20 +08:00
DiheChen dd639169f9 feat: manage member playlists with server-side track metadata 2026-11-09 01:31:15 +08:00
DiheChen d103d1278b feat: store playlists with a fixed favorites list for every account 2026-11-09 01:27:43 +08:00
DiheChen e69dc18581 feat: bind room tickets to member sessions and disconnect revoked sessions 2026-11-08 22:41:00 +08:00
DiheChen 54efdb6dd2 feat: register and sign in from the settings page 2026-11-08 22:05:21 +08:00
DiheChen eb4202c8ba feat: switch registration and reset account passwords in the admin page 2026-11-08 22:04:56 +08:00
DiheChen 791171d5ab feat: expose account registration, login and admin recovery over HTTP 2026-11-08 22:01:12 +08:00
DiheChen b8d9fac4c3 feat: register, log in and manage member accounts 2026-11-08 21:49:50 +08:00
DiheChen cd61d33248 feat: store whether registration is open 2026-11-08 21:48:23 +08:00
DiheChen 1277b3ac85 feat: store accounts and per-device sessions for members 2026-11-08 21:46:22 +08:00
DiheChen 026b7e7f1a feat: reject emails that are not shaped like name@example.com when updating a profile 2026-11-08 21:45:02 +08:00
DiheChen fa928102d1 feat: take the client address from trusted proxies 2026-11-08 21:43:35 +08:00
95 changed files with 7676 additions and 402 deletions

No files matched your search

+8
View File
@@ -102,6 +102,8 @@ func (g *goDecoder) object(name string, s *schema) {
at := fmt.Sprintf("field(path, %q)", f)
decode := "decodeString"
switch {
case p.Type == "array":
decode = fmt.Sprintf("decodeArray(decode%s)", refName(p.Items.Ref))
case p.Ref != "":
decode = "decode" + refName(p.Ref)
case p.Type == "integer":
@@ -149,6 +151,12 @@ func (g *goDecoder) checks(value, at string, s *schema, patternName string) {
g.patterns = append(g.patterns, goPattern{name: name, expr: s.Pattern})
check("checkPattern", name)
}
if s.MinItems != nil {
check("checkMinItems", fmt.Sprint(*s.MinItems))
}
if s.MaxItems != nil {
check("checkMaxItems", fmt.Sprint(*s.MaxItems))
}
if s.Minimum != nil {
check("checkMinimum", fmt.Sprint(*s.Minimum))
}
+33 -3
View File
@@ -107,6 +107,8 @@ type schema struct {
Required []string `json:"required,omitempty"`
AdditionalProperties *bool `json:"additionalProperties,omitempty"`
Items *schema `json:"items,omitempty"`
MinItems *int `json:"minItems,omitempty"`
MaxItems *int `json:"maxItems,omitempty"`
AnyOf []*schema `json:"anyOf,omitempty"`
OneOf []*schema `json:"oneOf,omitempty"`
AllOf []*schema `json:"allOf,omitempty"`
@@ -439,21 +441,44 @@ func (d *idl) checkField(where string, s *schema, required, inbound bool) error
}
return d.checkValue(where+".anyOf[0]", s.AnyOf[0], inbound)
case s.Type == "array":
if inbound || !required {
return fmt.Errorf("%s:数组只能出现在响应和事件中,并且必须是必填字段", where)
if !required {
return fmt.Errorf("%s:数组必须是必填字段", where)
}
if err := allowOnly(where, s, "type", "description", "items"); err != nil {
allowed := []string{"type", "description", "items"}
if inbound {
allowed = append(allowed, "minItems", "maxItems")
}
if err := allowOnly(where, s, allowed...); err != nil {
return err
}
if s.Items == nil {
return fmt.Errorf("%s:数组缺少 items", where)
}
if inbound {
if err := d.checkInboundArray(where, s); err != nil {
return err
}
}
return d.checkValue(where+".items", s.Items, inbound)
default:
return d.checkValue(where, s, inbound)
}
}
// 请求中的数组元素只能引用对象或枚举类型,解码函数按类型名生成。
func (d *idl) checkInboundArray(where string, s *schema) error {
if s.Items.Ref == "" || d.Defs[refName(s.Items.Ref)] != nil && d.Defs[refName(s.Items.Ref)].scalar() {
return fmt.Errorf("%s:请求中的数组元素只能引用对象或枚举类型", where)
}
if s.MinItems != nil && *s.MinItems < 0 || s.MaxItems != nil && *s.MaxItems < 0 {
return fmt.Errorf("%s:minItems 和 maxItems 不能是负数", where)
}
if s.MinItems != nil && s.MaxItems != nil && *s.MinItems > *s.MaxItems {
return fmt.Errorf("%s:minItems 大于 maxItems", where)
}
return nil
}
func (d *idl) checkValue(where string, s *schema, inbound bool) error {
if s.Ref != "" {
if err := allowOnly(where, s, "$ref", "description"); err != nil {
@@ -962,6 +987,9 @@ func topLevelNames(f *ast.File) []string {
func (d *idl) markInbound(s *schema) error {
for _, name := range slices.Sorted(maps.Keys(s.Properties)) {
p := s.Properties[name]
if p.Type == "array" {
p = p.Items
}
if p.Ref == "" || d.inbound[refName(p.Ref)] || d.Defs[refName(p.Ref)].scalar() {
continue
}
@@ -1055,6 +1083,8 @@ func (s *schema) keywords() []string {
add(s.Required != nil, "required")
add(s.AdditionalProperties != nil, "additionalProperties")
add(s.Items != nil, "items")
add(s.MinItems != nil, "minItems")
add(s.MaxItems != nil, "maxItems")
add(s.AnyOf != nil, "anyOf")
add(s.OneOf != nil, "oneOf")
add(s.AllOf != nil, "allOf")
+17 -6
View File
@@ -143,17 +143,28 @@ func TestParseIDLRejects(t *testing.T) {
{"required 写了不存在的字段", func(d map[string]any) {
defs(d)["ErrorInfo"] = object(map[string]any{"message": map[string]any{"type": "string"}}, "message", "code")
}, "required 中的 code 不在 properties 里"},
{"请求中有数组", func(d map[string]any) {
{"请求中有标量数组", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "ids"))}
}, "数组只能出现在响应和事件中"},
{"请求引用的类型中有数组", func(d map[string]any) {
}, "请求中的数组元素只能引用对象或枚举类型"},
{"请求引用的类型中有可选数组", func(d map[string]any) {
defs(d)["Batch"] = object(map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, "ids")
"ids": map[string]any{"type": "array", "items": ref("Empty")},
})
d["x-requests"] = []any{roomRequest(object(map[string]any{"batch": ref("Batch")}, "batch"))}
}, "$defs.Batch.properties.ids:数组只能出现在响应和事件中"},
}, "$defs.Batch.properties.ids:数组必须是必填字段"},
{"请求数组的 minItems 大于 maxItems", func(d map[string]any) {
d["x-requests"] = []any{roomRequest(object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 2, "maxItems": 1},
}, "ids"))}
}, "minItems 大于 maxItems"},
{"响应中的数组写了 minItems", func(d map[string]any) {
defs(d)["Batch"] = object(map[string]any{
"ids": map[string]any{"type": "array", "items": ref("Empty"), "minItems": 1},
}, "ids")
d["x-events"] = []any{map[string]any{"type": "batch", "data": ref("Batch")}}
}, "不支持 minItems"},
{"生成的类型名重名", func(d map[string]any) {
defs(d)["RoomSyncRequest"] = object(map[string]any{})
d["x-requests"] = []any{roomRequest(object(map[string]any{}))}
+25
View File
@@ -48,6 +48,10 @@ func DecodeCreateMemberRequest(body []byte) (CreateMemberRequest, error) {
return decodeCreateMemberRequest(body, "")
}
func DecodeRemoveTracksRequest(body []byte) (RemoveTracksRequest, error) {
return decodeRemoveTracksRequest(body, "")
}
func DecodeCreateRoomRequest(body []byte) (CreateRoomRequest, error) {
return decodeCreateRoomRequest(body, "")
}
@@ -257,6 +261,27 @@ func decodeQueueRemoveRequest(raw json.RawMessage, path string) (QueueRemoveRequ
return v, nil
}
func decodeRemoveTracksRequest(raw json.RawMessage, path string) (RemoveTracksRequest, error) {
var v RemoveTracksRequest
m, err := decodeObject(raw, path, "tracks")
if err != nil {
return v, err
}
if err = requireFields(m, path, "tracks"); err != nil {
return v, err
}
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
return v, err
}
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 3); err != nil {
return v, err
}
return v, nil
}
func decodeRoomSyncRequest(raw json.RawMessage, path string) (RoomSyncRequest, error) {
var v RoomSyncRequest
_, err := decodeObject(raw, path)
+15
View File
@@ -212,6 +212,21 @@
"required": ["member", "token"]
}
},
{
"name": "removeTracks",
"method": "POST",
"path": "/api/tracks/removals",
"auth": "none",
"status": 204,
"request": {
"type": "object",
"additionalProperties": false,
"properties": {
"tracks": {"type": "array", "items": {"$ref": "#/$defs/Track"}, "minItems": 1, "maxItems": 3}
},
"required": ["tracks"]
}
},
{
"name": "getRoom",
"method": "GET",
+49 -1
View File
@@ -183,6 +183,23 @@
},
"type": "object"
},
"RemoveTracksRequest": {
"additionalProperties": false,
"properties": {
"tracks": {
"items": {
"$ref": "#/components/schemas/Track"
},
"maxItems": 3,
"minItems": 1,
"type": "array"
}
},
"required": [
"tracks"
],
"type": "object"
},
"Room": {
"additionalProperties": false,
"description": "房间",
@@ -318,7 +335,7 @@
},
"info": {
"title": "Sample",
"version": "362d144af81ba8bf"
"version": "5e495c1e23fa03a5"
},
"openapi": "3.1.0",
"paths": {
@@ -774,6 +791,37 @@
],
"summary": "座位上的成员,<b>座位号</b> 从 1 开始 & 不超过 8"
}
},
"/api/tracks/removals": {
"post": {
"operationId": "removeTracks",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RemoveTracksRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "成功"
},
"default": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
},
"description": "失败"
}
},
"security": []
}
}
}
}
+14 -1
View File
@@ -4,7 +4,7 @@ package protocol
import "encoding/json"
const Version = "362d144af81ba8bf"
const Version = "5e495c1e23fa03a5"
type AdminUpdateRoomRequest struct {
Homepage *string `json:"homepage,omitempty"`
@@ -88,6 +88,18 @@ type QueueRemoveRequest struct {
EntryID string `json:"entryId"`
}
type RemoveTracksRequest struct {
Tracks []Track `json:"tracks"`
}
func (v RemoveTracksRequest) MarshalJSON() ([]byte, error) {
type plain RemoveTracksRequest
if v.Tracks == nil {
v.Tracks = []Track{}
}
return json.Marshal(plain(v))
}
type Room struct {
Gain float64 `json:"gain"`
ID string `json:"id"`
@@ -154,6 +166,7 @@ const (
var (
RouteCreateMember = Route{Pattern: "POST /api/members", Auth: AuthNone, Status: 201}
RouteRemoveTracks = Route{Pattern: "POST /api/tracks/removals", Auth: AuthNone, Status: 204}
RouteGetRoom = Route{Pattern: "GET /api/rooms/{ref}", Auth: AuthNone, Status: 200}
RouteCreateRoom = Route{Pattern: "POST /api/rooms", Auth: AuthNone, Status: 201, Location: true}
RouteGetSeat = Route{Pattern: "GET /api/rooms/{ref}/seats/{seat}", Auth: AuthMember, Status: 200}
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { ErrorInfo, Member, Room } from '../realtime/protocol.gen'
import type { ErrorInfo, Member, Room, Track } from '../realtime/protocol.gen'
export interface AdminUpdateRoomRequest {
homepage?: string
@@ -25,6 +25,10 @@ export interface CreateRoomResponse {
id: string
}
export interface RemoveTracksRequest {
tracks: Track[]
}
export interface ErrorResponse {
error: ErrorInfo
}
@@ -43,6 +47,7 @@ export type HttpSend = <T>(request: HttpRequest) => Promise<T>
export function httpClient(send: HttpSend) {
return {
createMember: () => send<CreateMemberResponse>({ method: 'POST', path: '/api/members', auth: 'none', body: {} }),
removeTracks: (body: RemoveTracksRequest) => send<void>({ method: 'POST', path: '/api/tracks/removals', auth: 'none', body }),
getRoom: (ref: string) => send<Room>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}`, auth: 'none' }),
createRoom: (body: CreateRoomRequest) => send<CreateRoomResponse>({ method: 'POST', path: '/api/rooms', auth: 'none', body }),
getSeat: (ref: string, seat: string) => send<Member>({ method: 'GET', path: `/api/rooms/${encodeURIComponent(ref)}/seats/${encodeURIComponent(seat)}`, auth: 'member' }),
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '362d144af81ba8bf'
export const PROTOCOL_VERSION = '5e495c1e23fa03a5'
export type Empty = Record<string, never>
+39
View File
@@ -0,0 +1,39 @@
-- +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;
-16
View File
@@ -1,16 +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;
-6
View File
@@ -1,6 +0,0 @@
-- +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);
+35
View File
@@ -0,0 +1,35 @@
-- +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);
@@ -1,5 +0,0 @@
-- +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;
-15
View File
@@ -1,15 +0,0 @@
-- +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);
+5 -4
View File
@@ -9,15 +9,16 @@ 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
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
WHERE id = @id
RETURNING *;
-- name: MemberUsername :one
SELECT username FROM members WHERE id = @id;
-- name: MemberAccountProfile :one
SELECT username, email FROM members WHERE id = @id;
-- name: RegisterMember :execrows
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
+106
View File
@@ -0,0 +1,106 @@
-- 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;
+5 -5
View File
@@ -35,10 +35,6 @@ 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 {
@@ -75,5 +71,9 @@ func (s *server) adminResetAccountPassword(ctx context.Context, c call) (any, er
}
func accountDTO(a identity.Account) protocol.Account {
return protocol.Account{Username: a.Username}
dto := protocol.Account{Username: a.Username}
if a.Email != "" {
dto.Email = &a.Email
}
return dto
}
+33 -10
View File
@@ -22,10 +22,12 @@ 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"
@@ -37,15 +39,16 @@ const (
)
type Services struct {
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
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
// 浏览器访问对象存储时使用的地址前缀,结尾没有 /。
StorageURL string
@@ -109,7 +112,16 @@ 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.RouteLogoutOtherSessions, srv.logoutOtherSessions)
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.RouteListRooms, srv.listRooms)
srv.handle(mux, protocol.RouteGetRoom, srv.getRoom)
srv.handle(mux, protocol.RouteCreateRoom, srv.createRoom)
@@ -294,6 +306,7 @@ 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},
@@ -303,6 +316,16 @@ 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},
+1 -1
View File
@@ -299,7 +299,7 @@ func TestAbortHandlerIsLeftToNetHTTP(t *testing.T) {
type brokenProfiles struct{ *porttest.Store }
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string) (member.Member, error) {
func (brokenProfiles) UpdateMember(context.Context, string, *string, *string, *string) (member.Member, error) {
return member.Member{}, errors.New("连接中断")
}
+25 -9
View File
@@ -48,11 +48,7 @@ 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 {
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{Track: trackOf(*d.Track), Requester: deref(d.Requester)}
}
return realtime.Pick{Source: track.Source(*d.Source), Keyword: *d.Keyword, Requester: deref(d.Requester)}
}
@@ -145,18 +141,38 @@ 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: meta(e.Meta),
Meta: s.trackMeta(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
+146
View File
@@ -0,0 +1,146 @@
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 FROM members WHERE username = $1
SELECT id, name, avatar_hash, created_at, username, password_hash, email FROM members WHERE username = $1
`
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
@@ -23,13 +23,14 @@ 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
RETURNING id, name, avatar_hash, created_at, username, password_hash, email
`
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
@@ -42,6 +43,7 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error)
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
)
return i, err
}
@@ -115,8 +117,24 @@ 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 FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
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)
`
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
@@ -129,21 +147,11 @@ 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
@@ -184,20 +192,27 @@ 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
WHERE id = $3
RETURNING id, name, avatar_hash, created_at, username, password_hash
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
`
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.ID)
row := q.db.QueryRow(ctx, updateMember,
arg.Name,
arg.AvatarHash,
arg.Email,
arg.ID,
)
var i Member
err := row.Scan(
&i.ID,
@@ -206,6 +221,7 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
&i.CreatedAt,
&i.Username,
&i.PasswordHash,
&i.Email,
)
return i, err
}
@@ -15,6 +15,22 @@ 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 {
@@ -0,0 +1,487 @@
// 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
}
+19 -20
View File
@@ -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 *string) (member.Member, error) {
row, err := s.q.UpdateMember(ctx, query.UpdateMemberParams{ID: id, Name: name, AvatarHash: avatarHash})
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})
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
@@ -58,24 +58,31 @@ func memberOf(row query.Member) member.Member {
return member.Member{ID: row.ID, Name: row.Name, AvatarHash: orEmpty(row.AvatarHash)}
}
func (s *Store) Username(ctx context.Context, memberID string) (string, error) {
username, err := s.q.MemberUsername(ctx, memberID)
func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.AccountProfile, error) {
row, err := s.q.MemberAccountProfile(ctx, memberID)
if errors.Is(err, pgx.ErrNoRows) {
err = port.ErrMemberNotFound
}
if err != nil {
return "", fmt.Errorf("读取成员 %s 的用户名:%w", memberID, err)
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
}
return orEmpty(username), nil
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, nil
}
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
n, err := s.q.RegisterMember(ctx, query.RegisterMemberParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
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)
})
if err != nil {
return fmt.Errorf("注册成员 %s:%w", memberID, usernameError(err))
}
if n == 0 {
return fmt.Errorf("注册成员 %s:%w", memberID, port.ErrAlreadyRegistered)
return fmt.Errorf("注册成员 %s:%w", memberID, err)
}
return nil
}
@@ -96,7 +103,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), PasswordHash: orEmpty(row.PasswordHash)}, nil
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil
}
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
@@ -131,14 +138,6 @@ 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 {
+79 -13
View File
@@ -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")); err != nil {
if _, err := store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil); 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(""))
got, err := store.UpdateMember(t.Context(), m.ID, nil, ptr(""), nil)
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)
got, err := store.UpdateMember(t.Context(), m.ID, ptr("甲"), nil, 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"))
got, err = store.UpdateMember(t.Context(), m.ID, nil, ptr("0bc83cb571cd1c50ba6f3e8a78ef1346"), nil)
if err != nil {
t.Fatal(err)
}
@@ -96,11 +96,74 @@ 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); !errors.Is(err, port.ErrMemberNotFound) {
if _, err := store.UpdateMember(t.Context(), "01a15778-1911-7a28-8b2e-e08c2a25f388", ptr("乙"), nil, 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))
@@ -117,14 +180,14 @@ func TestRegisterMember(t *testing.T) {
store, _ := newStore(t)
alice := createMember(t, store, "alice", "游客-1a2b")
bob := createMember(t, store, "bob", "游客-3c4d")
if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "" {
t.Fatalf("游客的用户名 = %q, %v,期望空字符串", username, err)
if profile, err := store.AccountProfile(t.Context(), alice.ID); err != nil || profile != (port.AccountProfile{}) {
t.Fatalf("游客的 AccountProfile = %#v, %v,期望两个字段都为空", profile, err)
}
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); err != nil {
t.Fatal(err)
}
if username, err := store.Username(t.Context(), alice.ID); err != nil || username != "alice" {
t.Errorf("注册之后的用户名 = %q, %v,期望 alice", username, 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)
}
account, err := store.AccountByUsername(t.Context(), "alice")
if want := (port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a"}); err != nil || account != want {
@@ -139,7 +202,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.Username(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) {
if _, err := store.AccountProfile(t.Context(), "00000000-0000-7000-8000-000000000000"); !errors.Is(err, port.ErrMemberNotFound) {
t.Errorf("不存在的成员 = %v,期望 ErrMemberNotFound", err)
}
}
@@ -160,12 +223,15 @@ func TestMemberSessions(t *testing.T) {
t.Errorf("%s 属于成员 %q,期望 %s", s.token, got, alice.ID)
}
}
deleted, err := store.DeleteSessions(t.Context(), alice.ID, tokenHash("alice-1"))
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"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("DeleteSessions 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
t.Errorf("SetPassword 返回 %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("DeleteSessions 应只留下指定的会话")
t.Error("SetPassword 应只留下指定的会话")
}
if err := store.DeleteSession(t.Context(), tokenHash("alice-1")); err != nil {
t.Fatal(err)
+10 -21
View File
@@ -195,28 +195,20 @@ func TestTicketSessionsMigrationBindsTicketsToSessions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := provider.UpTo(ctx, 5); err != nil {
if _, err := provider.UpTo(ctx, 2); 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)
}
}
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)
}
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{"single-ticket": single, "multiple-ticket": multiple, "api-key": bot} {
for ticket, memberID := range map[string]string{"guest-ticket": guest, "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)
}
@@ -226,11 +218,8 @@ func TestTicketSessionsMigrationBindsTicketsToSessions(t *testing.T) {
}
store := New(pool)
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("guest-ticket")); err != nil || !bytes.Equal(holder.SessionTokenHash, tokenHash("guest")) {
t.Errorf("游客的票据 = %#v, %v,期望归到游客的会话", holder, 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)
+307
View File
@@ -0,0 +1,307 @@
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
}
@@ -0,0 +1,538 @@
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)
renamed, err := store.UpdateMember(t.Context(), alice.ID, ptr("新名字"), nil, nil)
if err != nil {
t.Fatal(err)
}
@@ -83,14 +83,17 @@ func TestTicketsBelongToSessions(t *testing.T) {
return err == nil
}
deleted, err := store.DeleteSessions(t.Context(), alice.ID, tokenHash("alice-1"))
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"))
if want := [][]byte{tokenHash("alice-2"), tokenHash("alice-3")}; err != nil || !sameHashes(deleted, want) {
t.Errorf("DeleteSessions 返回 %x, %v,期望被删除的 alice-2、alice-3", deleted, err)
t.Errorf("SetPassword 返回 %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"), ""); err != nil {
if err := store.ReplaceSession(t.Context(), tokenHash("guest-4"), alice.ID, tokenHash("alice-4"), "new"); err != nil {
t.Fatal(err)
}
if valid("guest-4") {
+6 -12
View File
@@ -35,6 +35,7 @@ const (
type Account struct {
Username string
Email string
}
// 删除会话之后,用这些会话的票据建立的房间连接立即断开;数据已经修改而断开的通知没有发出时返回错误,
@@ -53,11 +54,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) {
username, err := a.members.Username(ctx, memberID)
profile, err := a.members.AccountProfile(ctx, memberID)
if err != nil {
return Account{}, false, err
}
return Account{Username: username}, username != "", nil
return Account{Username: profile.Username, Email: profile.Email}, profile.Username != "", nil
}
// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。
@@ -115,7 +116,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}, nil
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, nil
}
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
@@ -155,20 +156,13 @@ 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 {
username, err := a.members.Username(ctx, memberID)
profile, err := a.members.AccountProfile(ctx, memberID)
if err != nil {
return err
}
username := profile.Username
if username == "" {
return ErrNotRegistered
}
+32 -22
View File
@@ -147,6 +147,37 @@ 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 {
@@ -253,29 +284,9 @@ 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, otherTicket := issueTicket(t, s, alice, id), issueTicket(t, s, other, id)
aliceTicket := issueTicket(t, s, alice, 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)
}
@@ -399,7 +410,6 @@ 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")
}},
+15 -6
View File
@@ -3,6 +3,8 @@ package identity
import (
"context"
"errors"
"regexp"
"strings"
"github.com/dihemoe/alisten/internal/app/port"
"github.com/dihemoe/alisten/internal/app/realtime"
@@ -15,8 +17,11 @@ 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
@@ -55,17 +60,21 @@ 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 *string
var avatar, email *string
if p.Email != nil {
hash := avatarHash(*p.Email)
avatar = &hash
trimmed := strings.TrimSpace(*p.Email)
if trimmed != "" && !emailPattern.MatchString(trimmed) {
return member.Member{}, ErrInvalidEmail
}
hash := avatarHash(trimmed)
avatar, email = &hash, &trimmed
}
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar)
m, err := s.members.UpdateMember(ctx, memberID, p.Name, avatar, email)
if err != nil {
return member.Member{}, err
}
@@ -97,7 +106,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 {
+17
View File
@@ -96,6 +96,23 @@ 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
}
+189
View File
@@ -0,0 +1,189 @@
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)
}
+293
View File
@@ -0,0 +1,293 @@
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)
}
}
+13 -8
View File
@@ -19,20 +19,26 @@ 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 为 nil 时保持原值。
UpdateMember(ctx context.Context, id string, name, avatarHash *string) (member.Member, error)
// 游客的用户名是空字符串。
Username(ctx context.Context, memberID string) (string, error)
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。
// 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。同时为这个账号创建“我喜欢”。
Register(ctx context.Context, memberID, username, passwordHash string) error
AccountByUsername(ctx context.Context, username string) (Account, error)
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
@@ -40,8 +46,7 @@ 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 哈希。
DeleteSessions(ctx context.Context, memberID string, keepTokenHash []byte) ([][]byte, error)
// 在同一个事务中修改已注册成员的密码哈希,并像 DeleteSessions 一样删除会话。成员是游客时返回 ErrMemberNotFound。
// 在同一个事务中修改已注册成员的密码哈希,并删除该成员除 keepTokenHash 以外的会话,keepTokenHash 为 nil 时全部删除。
// 返回被删除的会话的 token 哈希。成员是游客时返回 ErrMemberNotFound。
SetPassword(ctx context.Context, memberID, passwordHash string, keepTokenHash []byte) ([][]byte, error)
}
+62
View File
@@ -0,0 +1,62 @@
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
}
+244 -12
View File
@@ -15,7 +15,9 @@ 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 适配器一致,包括它会拒绝的输入。
@@ -33,11 +35,13 @@ 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
}
@@ -55,6 +59,7 @@ var (
_ port.Credentials = (*Store)(nil)
_ port.APIKeys = (*Store)(nil)
_ port.Site = (*Store)(nil)
_ port.Playlists = (*Store)(nil)
)
func New() *Store {
@@ -118,14 +123,20 @@ func (s *Store) member(id string) *storedMember {
return nil
}
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *string) (member.Member, error) {
func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, email *string) (member.Member, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.members {
m := &s.members[i].member
stored := &s.members[i]
m := &stored.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
@@ -135,19 +146,22 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash *str
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) Username(_ context.Context, memberID string) (string, error) {
func (s *Store) AccountProfile(_ context.Context, memberID string) (port.AccountProfile, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.member(memberID)
if m == nil {
return "", port.ErrMemberNotFound
return port.AccountProfile{}, port.ErrMemberNotFound
}
return m.username, nil
return port.AccountProfile{Username: m.username, Email: m.email}, nil
}
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
@@ -164,6 +178,8 @@ 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
}
@@ -172,7 +188,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, PasswordHash: m.passwordHash}, nil
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash}, nil
}
}
return port.Account{}, port.ErrMemberNotFound
@@ -206,12 +222,6 @@ 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()
@@ -492,3 +502,225 @@ 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
}
+15
View File
@@ -0,0 +1,15 @@
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 的邮箱应报错")
}
}
+11 -9
View File
@@ -28,6 +28,7 @@ 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"
@@ -77,15 +78,16 @@ 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")),
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")),
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),
StorageURL: cfg.storageURL,
TrustedProxies: cfg.trustedProxies,
+66 -32
View File
@@ -1,4 +1,4 @@
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token,自己原来的 token 随即失效;修改密码、退出其他设备、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
账号:游客注册之后成员和成员 token 都不变;其他游客用账号登录,得到新的成员 token,自己原来的 token 随即失效;修改密码、退出登录让对应的成员 token 失效;后台可以查找账号、重置密码。
-- request --
POST /api/members
@@ -24,6 +24,22 @@ 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
@@ -48,6 +64,31 @@ 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
@@ -91,7 +132,30 @@ Authorization: Bearer $bob
{"username": "alice", "password": "correct horse"}
-- response --
201
{"memberToken": "$alice2", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}, "account": {"username": "alice"}}
{"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"}}
-- request --
GET /api/me
@@ -133,36 +197,6 @@ 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
View File
@@ -0,0 +1,353 @@
歌单:只有已注册的账号能使用;注册时自带不能重命名、不能删除的“我喜欢”;收藏时元数据由服务端查询,同一首歌只收一次,新加入的在前;可以批量移出、复制到别的歌单、调整顺序;别人的歌单和不存在的一样。
-- 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"}]}
+9
View File
@@ -36,6 +36,15 @@ 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
+4 -2
View File
@@ -1,4 +1,4 @@
票据属于签发它的会话:账号在两台设备上各自进入同一个房间,一台设备退出其他设备之后,另一台设备的 WebSocket 以 4401 断开,
票据属于签发它的会话:账号在两台设备上各自进入同一个房间,一台设备修改密码之后,另一台设备的 WebSocket 以 4401 断开,
用它的票据打开的事件流收到 4401 的 close;这台设备的旧票据再也不能用来进房或调用房间接口。发出请求的设备不受影响。
-- request --
@@ -96,8 +96,10 @@ 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 --
DELETE /api/sessions/others
PUT /api/account/password
Authorization: Bearer $alice
{"currentPassword": "correct horse", "newPassword": "battery staple"}
-- response --
204
-- closed b --
+44
View File
@@ -0,0 +1,44 @@
package playlist
import (
"errors"
"slices"
"github.com/dihemoe/alisten/internal/domain/track"
)
var (
ErrBeforeMoved = errors.New("before 不能是要移动的歌")
ErrBeforeMissing = errors.New("歌单已经变了,刷新之后再试")
)
// order 是显示顺序;返回移动后的显示顺序。moved 里不在 order 中的歌忽略,重复的只算一次;
// 都不在 order 中时原样返回,此时不检查 before 是否还在。
func Move(order []track.Track, moved []track.Track, before *track.Track) ([]track.Track, error) {
if before != nil && slices.Contains(moved, *before) {
return nil, ErrBeforeMoved
}
var block []track.Track
for _, t := range moved {
if slices.Contains(order, t) && !slices.Contains(block, t) {
block = append(block, t)
}
}
if len(block) == 0 {
return slices.Clone(order), nil
}
rest := make([]track.Track, 0, len(order)-len(block))
for _, t := range order {
if !slices.Contains(block, t) {
rest = append(rest, t)
}
}
at := len(rest)
if before != nil {
at = slices.Index(rest, *before)
if at < 0 {
return nil, ErrBeforeMissing
}
}
return slices.Concat(rest[:at], block, rest[at:]), nil
}
+60
View File
@@ -0,0 +1,60 @@
package playlist
import (
"errors"
"slices"
"testing"
"github.com/dihemoe/alisten/internal/domain/track"
)
func tr(id string) track.Track { return track.Track{Source: track.NetEase, ID: id} }
func trs(ids ...string) []track.Track {
out := make([]track.Track, len(ids))
for i, id := range ids {
out[i] = tr(id)
}
return out
}
func TestMove(t *testing.T) {
order := trs("a", "b", "c", "d", "e")
at := func(id string) *track.Track { t := tr(id); return &t }
tests := []struct {
name string
moved []track.Track
before *track.Track
want []track.Track
wantErr error
}{
{"移到最前", trs("d"), at("a"), trs("d", "a", "b", "c", "e"), nil},
{"移到最后", trs("b"), nil, trs("a", "c", "d", "e", "b"), nil},
{"移到中间", trs("a"), at("d"), trs("b", "c", "a", "d", "e"), nil},
{"多首保持给定先后", trs("e", "b"), at("d"), trs("a", "c", "e", "b", "d"), nil},
{"多首移到最后保持给定先后", trs("c", "a"), nil, trs("b", "d", "e", "c", "a"), nil},
{"忽略不存在的歌", trs("x", "c", "y"), at("a"), trs("c", "a", "b", "d", "e"), nil},
{"重复的歌只算一次", trs("c", "c", "b"), nil, trs("a", "d", "e", "c", "b"), nil},
{"before 紧跟在移动的歌之后", trs("b"), at("c"), order, nil},
{"before 是被移动的歌", trs("b", "c"), at("c"), nil, ErrBeforeMoved},
{"before 是不存在的被移动的歌", trs("x"), at("x"), nil, ErrBeforeMoved},
{"before 不存在", trs("b"), at("x"), nil, ErrBeforeMissing},
{"空移动", nil, at("c"), order, nil},
{"移动的歌都不存在", trs("x"), at("y"), order, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
before := slices.Clone(order)
got, err := Move(order, tt.moved, tt.before)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v,期望 %v", err, tt.wantErr)
}
if !slices.Equal(got, tt.want) {
t.Errorf("Move = %v,期望 %v", got, tt.want)
}
if !slices.Equal(order, before) {
t.Errorf("order 被修改:%v", order)
}
})
}
}
+167
View File
@@ -86,6 +86,34 @@ 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, "")
}
@@ -146,6 +174,21 @@ 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")
@@ -419,12 +462,54 @@ 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")
@@ -496,6 +581,21 @@ 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")
@@ -523,6 +623,34 @@ func decodeLoginRequest(raw json.RawMessage, path string) (LoginRequest, error)
return v, nil
}
func decodeMoveMyPlaylistItemsRequest(raw json.RawMessage, path string) (MoveMyPlaylistItemsRequest, error) {
var v MoveMyPlaylistItemsRequest
m, err := decodeObject(raw, path, "before", "tracks")
if err != nil {
return v, err
}
if err = requireFields(m, path, "tracks"); err != nil {
return v, err
}
if r, ok := m["before"]; ok {
x, err := decodeTrack(r, field(path, "before"))
if err != nil {
return v, err
}
v.Before = &x
}
if v.Tracks, err = decodeArray(decodeTrack)(m["tracks"], field(path, "tracks")); err != nil {
return v, err
}
if err = checkMinItems(v.Tracks, field(path, "tracks"), 1); err != nil {
return v, err
}
if err = checkMaxItems(v.Tracks, field(path, "tracks"), 1000); err != nil {
return v, err
}
return v, nil
}
func decodePingRequest(raw json.RawMessage, path string) (PingRequest, error) {
var v PingRequest
m, err := decodeObject(raw, path, "clientTimeMs")
@@ -701,6 +829,45 @@ 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)
+32
View File
@@ -88,6 +88,38 @@ func decodeInteger(raw json.RawMessage, path string) (int64, error) {
return n, nil
}
func decodeArray[T any](decode func(json.RawMessage, string) (T, error)) func(json.RawMessage, string) ([]T, error) {
return func(raw json.RawMessage, path string) ([]T, error) {
var elems []json.RawMessage
if bytes.Equal(raw, null) || json.Unmarshal(raw, &elems) != nil {
return nil, invalid(path, "应为数组")
}
out := make([]T, len(elems))
for i, e := range elems {
v, err := decode(e, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
out[i] = v
}
return out, nil
}
}
func checkMinItems[T any](a []T, path string, n int) error {
if len(a) < n {
return invalid(path, fmt.Sprintf("元素不能少于 %d 个", n))
}
return nil
}
func checkMaxItems[T any](a []T, path string, n int) error {
if len(a) > n {
return invalid(path, fmt.Sprintf("元素不能超过 %d 个", n))
}
return nil
}
func checkMinLength(s, path string, n int) error {
if utf8.RuneCountInString(s) < n {
return invalid(path, fmt.Sprintf("长度不能少于 %d 个字符", n))
+25
View File
@@ -3,6 +3,7 @@ package protocol
import (
"encoding/json"
"errors"
"strings"
"testing"
)
@@ -67,3 +68,27 @@ func TestCheckOneOf(t *testing.T) {
}
}
}
func TestDecodeTrackArray(t *testing.T) {
one := `{"source":"netease","id":"1"}`
tests := []struct {
body string
want string
}{
{`{"tracks":[` + one + `]}`, ""},
{`{"tracks":[]}`, "tracks:元素不能少于 1 个"},
{`{"tracks":null}`, "tracks:应为数组"},
{`{"tracks":[{"source":"netease"}]}`, "tracks[0].id:缺少必填字段"},
{`{}`, "tracks:缺少必填字段"},
}
for _, tt := range tests {
_, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tt.body))
if got := errText(err); got != tt.want {
t.Errorf("%s = %q,期望 %q", tt.body, got, tt.want)
}
}
tooMany := `{"tracks":[` + one + strings.Repeat(","+one, 1000) + `]}`
if got := errText(func() error { _, err := DecodeRemoveMyPlaylistItemsRequest([]byte(tooMany)); return err }()); got != "tracks:元素不能超过 1000 个" {
t.Errorf("1001 首 = %q", got)
}
}
+197 -5
View File
@@ -156,7 +156,8 @@
"type": "object",
"additionalProperties": false,
"properties": {
"username": {"type": "string"}
"username": {"type": "string"},
"email": {"type": "string", "description": "账号设置了邮箱时才有"}
},
"required": ["username"]
},
@@ -169,6 +170,30 @@
},
"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,
@@ -704,7 +729,7 @@
},
{
"name": "updateMe",
"description": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像",
"description": "修改昵称和头像;email 用来计算头像哈希,已注册的账号同时保存它,去掉首尾空白后应形如 name@example.com,空字符串表示清除",
"method": "PATCH",
"path": "/api/me",
"auth": "member",
@@ -803,13 +828,180 @@
"status": 204
},
{
"name": "logoutOtherSessions",
"description": "退出其他设备:同一成员的其他成员 token 都失效,发出请求的这个不变",
"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": "删除歌单;“我喜欢”不能删除",
"method": "DELETE",
"path": "/api/sessions/others",
"path": "/api/me/playlists/{id}",
"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": "房间列表",
+683 -28
View File
@@ -17,6 +17,10 @@
"Account": {
"additionalProperties": false,
"properties": {
"email": {
"description": "账号设置了邮箱时才有",
"type": "string"
},
"username": {
"type": "string"
}
@@ -37,6 +41,30 @@
],
"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": {
@@ -413,6 +441,40 @@
],
"type": "object"
},
"CopyMyPlaylistItemsRequest": {
"additionalProperties": false,
"properties": {
"from": {
"description": "来源歌单的 ID",
"type": "string"
},
"tracks": {
"items": {
"$ref": "#/components/schemas/Track"
},
"maxItems": 1000,
"minItems": 1,
"type": "array"
}
},
"required": [
"from",
"tracks"
],
"type": "object"
},
"CopyMyPlaylistItemsResponse": {
"additionalProperties": false,
"properties": {
"added": {
"type": "integer"
}
},
"required": [
"added"
],
"type": "object"
},
"CreateMemberRequest": {
"additionalProperties": false,
"type": "object"
@@ -433,6 +495,20 @@
],
"type": "object"
},
"CreateMyPlaylistRequest": {
"additionalProperties": false,
"properties": {
"name": {
"description": "歌单名:去掉首尾空白后 1 到 50 个字符",
"maxLength": 200,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"CreateRoomRequest": {
"additionalProperties": false,
"properties": {
@@ -637,6 +713,63 @@
],
"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": {
@@ -734,6 +867,87 @@
],
"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": {
@@ -1045,6 +1259,37 @@
],
"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": {
@@ -1496,7 +1741,7 @@
},
"info": {
"title": "Alisten",
"version": "812b22c0beac413d"
"version": "12f9c11ba788ac55"
},
"openapi": "3.1.0",
"paths": {
@@ -2630,7 +2875,443 @@
"memberToken": []
}
],
"summary": "修改昵称和头像;email 只用来计算头像哈希,空字符串表示清除头像"
"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 首;不在歌单里的忽略"
}
},
"/api/members": {
@@ -3430,32 +4111,6 @@
"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",
+126 -3
View File
@@ -4,14 +4,15 @@ package protocol
import "encoding/json"
const Version = "812b22c0beac413d"
const Version = "12f9c11ba788ac55"
type APIKey struct {
Key string `json:"key"`
}
type Account struct {
Username string `json:"username"`
Email *string `json:"email,omitempty"`
Username string `json:"username"`
}
type ActivityKind string
@@ -25,6 +26,14 @@ 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"`
@@ -155,6 +164,23 @@ type ChatSendRequest struct {
Content string `json:"content"`
}
type CopyMyPlaylistItemsRequest struct {
From string `json:"from"`
Tracks []Track `json:"tracks"`
}
func (v CopyMyPlaylistItemsRequest) MarshalJSON() ([]byte, error) {
type plain CopyMyPlaylistItemsRequest
if v.Tracks == nil {
v.Tracks = []Track{}
}
return json.Marshal(plain(v))
}
type CopyMyPlaylistItemsResponse struct {
Added int64 `json:"added"`
}
type CreateMemberRequest struct{}
type CreateMemberResponse struct {
@@ -162,6 +188,10 @@ 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"`
@@ -244,6 +274,46 @@ 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"`
}
@@ -286,6 +356,34 @@ 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"`
@@ -442,6 +540,22 @@ 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"`
@@ -658,7 +772,16 @@ 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}
RouteLogoutOtherSessions = Route{Pattern: "DELETE /api/sessions/others", 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}
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}
+1 -1
View File
@@ -22,7 +22,7 @@ server {
include /tmp/alisten.d/*.conf;
location ~ ^/(?:admin|settings|history)?$ {
location ~ ^/(?:admin|settings|history|playlists(?:/[^/]+)?)?$ {
add_header Cache-Control "no-cache";
try_files /index.html =404;
}
+41 -20
View File
@@ -1,13 +1,14 @@
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, ListMusic, MessageCircle, MoreHorizontal, Search, Settings, Share2, UserRound, Wifi, WifiOff, X } from 'lucide-react'
import { ChevronLeft, ChevronRight, Headphones, History, House, Library, 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'
@@ -29,11 +30,12 @@ const AdminPage = lazyPage(() => import('@/features/admin').then((module) => {
const pageLoading = <div className="page-content"><Skeleton rows={6} /></div>
const menu: { page: 'lobby' | 'settings' | 'history' | RoomPage, label: string, icon: LucideIcon }[] = [
const menu: { page: 'lobby' | 'settings' | 'history' | 'playlists' | 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 },
]
@@ -45,9 +47,11 @@ const mobileMenu: { page: RoomPage, label: string, icon: LucideIcon }[] = [
]
const pageClasses: Partial<Record<Page, string>> = {
queue: 'scrollbar-hidden',
history: 'scrollbar-hidden',
settings: 'settings-view',
'queue': 'scrollbar-hidden',
'history': 'scrollbar-hidden',
'playlists': 'scrollbar-hidden',
'my-playlist': 'scrollbar-hidden',
'settings': 'settings-view',
}
function closeMenu(element: HTMLElement): void {
@@ -112,21 +116,24 @@ function Header({ page }: { page: Page }) {
<MoreHorizontal size={20} />
</summary>
<nav className="main-nav" aria-label="主导航">
{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>
))}
{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>
)
})}
<button
type="button"
className="nav-item text-ui-base"
@@ -187,6 +194,13 @@ 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')
@@ -258,6 +272,13 @@ 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>
+2
View File
@@ -5,6 +5,7 @@ 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'
@@ -26,6 +27,7 @@ performanceStore.start()
volumeStore.start()
brandStore.start()
profileStore.start()
playlistsStore.start()
playerStore.start()
reminderStore.start()
historyStore.start()
+2 -2
View File
@@ -65,11 +65,11 @@ class NavigationStore extends Store<NavigationState> {
}
// 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。
open(page: 'lobby' | 'admin' | 'settings' | 'history' | RoomPage): void {
open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void {
const { panel } = this.state.route
if (page === 'lobby' || page === 'history')
this.navigate({ name: page, filter: '', panel })
else if (page === 'admin' || page === 'settings')
else if (page === 'admin' || page === 'settings' || page === 'playlists')
this.navigate({ name: page, panel })
else
this.#openRoomPage(ref => ({ name: page, ref, panel }))
+8
View File
@@ -15,6 +15,9 @@ 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' }],
@@ -64,6 +67,11 @@ 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
View File
@@ -12,6 +12,8 @@ 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 }
@@ -68,6 +70,11 @@ 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)
@@ -97,7 +104,10 @@ 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':
@@ -126,6 +136,8 @@ 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')
+61 -2
View File
@@ -1,6 +1,14 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
import type { APIKey, Account, AdminAccount, ErrorInfo, Member, QQLoginType, QRLogin, QRLoginResult, RoomListing, SourceAccount, StoredMedia } from '../realtime/protocol.gen'
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
}
export interface AdminCheckBilibiliLoginRequest {
key: string
@@ -101,6 +109,15 @@ export interface ChangePasswordRequest {
newPassword: string
}
export interface CopyMyPlaylistItemsRequest {
from: string
tracks: Track[]
}
export interface CopyMyPlaylistItemsResponse {
added: number
}
export type CreateMemberRequest = Record<string, never>
export interface CreateMemberResponse {
@@ -108,6 +125,10 @@ export interface CreateMemberResponse {
memberToken: string
}
export interface CreateMyPlaylistRequest {
name: string
}
export interface CreateRoomRequest {
description: string
name: string
@@ -136,6 +157,22 @@ 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[]
}
@@ -151,6 +188,11 @@ export interface LoginResponse {
memberToken: string
}
export interface MoveMyPlaylistItemsRequest {
before?: Track
tracks: Track[]
}
export interface RegisterRequest {
password: string
username: string
@@ -160,6 +202,14 @@ export interface RegisterResponse {
account: Account
}
export interface RemoveMyPlaylistItemsRequest {
tracks: Track[]
}
export interface RenameMyPlaylistRequest {
name: string
}
export interface UpdateMeRequest {
email?: string
name?: string
@@ -193,7 +243,16 @@ 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' }),
logoutOtherSessions: () => send<void>({ method: 'DELETE', path: '/api/sessions/others', 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 }),
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 }),
+17 -1
View File
@@ -1,12 +1,13 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = '812b22c0beac413d'
export const PROTOCOL_VERSION = '12f9c11ba788ac55'
export interface APIKey {
key: string
}
export interface Account {
email?: string
username: string
}
@@ -71,6 +72,21 @@ 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,6 +2,7 @@ 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'
@@ -46,6 +47,7 @@ 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"
+2
View File
@@ -1,6 +1,7 @@
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'
@@ -90,6 +91,7 @@ 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
@@ -0,0 +1,41 @@
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>
)
}
+7
View File
@@ -0,0 +1,7 @@
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))
@@ -0,0 +1,273 @@
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>
)
}
@@ -0,0 +1,29 @@
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>
)
}
@@ -0,0 +1,35 @@
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')
})
})
@@ -0,0 +1,20 @@
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}`
}
@@ -0,0 +1,177 @@
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>
)
}
@@ -0,0 +1,69 @@
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])
})
})
@@ -0,0 +1,43 @@
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)))
}
@@ -0,0 +1,151 @@
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>
)
}
@@ -0,0 +1,220 @@
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>
)
}
@@ -0,0 +1,429 @@
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])
})
})
@@ -0,0 +1,243 @@
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()
+161
View File
@@ -0,0 +1,161 @@
.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); }
}
+5
View File
@@ -0,0 +1,5 @@
import { bookTitle } from '@/shared/lib/book-title'
export function saveToPlaylistLabel(title: string): string {
return `把${bookTitle(title)}保存到歌单`
}
@@ -0,0 +1,195 @@
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)} />}
</>
)
}
@@ -0,0 +1,36 @@
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>
)
}
@@ -0,0 +1,97 @@
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()
})
})
+50
View File
@@ -0,0 +1,50 @@
// 与后端的 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)))
}
@@ -0,0 +1,58 @@
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)
})
})
@@ -0,0 +1,38 @@
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 }
}
@@ -0,0 +1,180 @@
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 }
}
@@ -0,0 +1,49 @@
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 })
})
})
@@ -0,0 +1,42 @@
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))
}
+14 -6
View File
@@ -1,7 +1,9 @@
import type { Entry, PlayMode } from '@/core/realtime'
import type { Entry, PlayMode, Track } from '@/core/realtime'
import type { SongItem } from '@/shared/ui'
import { Heart, ListMusic, Trash2 } from 'lucide-react'
import { Heart, ListMusic, ListPlus, Trash2 } from 'lucide-react'
import { useState } from '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'
@@ -47,6 +49,7 @@ 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">
@@ -73,10 +76,14 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
? (
<SongList
songs={items.map(songItem)}
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) },
]}
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) },
]
}}
/>
)
: !pending && (
@@ -84,6 +91,7 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
每个人都可以点歌,一起决定下一首。
</EmptyState>
)}
{saving && <SaveToPlaylistDialog track={saving.track} title={saving.title} onClose={() => setSaving(null)} />}
</section>
)
}
+31 -50
View File
@@ -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 AccountSection() {
export function AccountRow() {
const member = useStore(profileStore, s => s.member)
const account = useStore(profileStore, s => s.account)
const failed = useStore(profileStore, s => s.failed)
@@ -50,58 +50,39 @@ export function AccountSection() {
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 (
<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 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>
</>
)}
</div>
)}
{(dialog === 'register' || dialog === 'login') && <CredentialsDialog mode={dialog} onClose={() => setDialog(null)} />}
{dialog === 'password' && <PasswordDialog onClose={() => setDialog(null)} />}
</section>
</div>
)
}
@@ -115,9 +96,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 })
@@ -142,8 +123,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 ? '注册' : '登录')}
@@ -180,7 +161,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,6 +32,7 @@ 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')])
@@ -45,12 +46,18 @@ 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) => {
if (`${init.method} ${path}` !== 'GET /api/me')
throw new Error(`没有预期的请求 ${init.method} ${path}`)
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}`)
return Promise.resolve(new Response(JSON.stringify(me), { status: 200 }))
})
})
@@ -106,7 +113,7 @@ describe('账号状态', () => {
it('游客没有账号', async () => {
const { profileStore } = await load()
profileStore.setAccount({ username: 'alice' })
await profileStore.registered({ username: 'alice' })
await profileStore.load()
expect(profileStore.state).toMatchObject({ member, account: null })
})
@@ -114,19 +121,88 @@ describe('账号状态', () => {
it('注册之后设置账号', async () => {
const { profileStore } = await load()
await profileStore.load()
profileStore.setAccount({ username: 'alice' })
await profileStore.registered({ username: 'alice' })
expect(profileStore.state).toMatchObject({ member, account: { username: 'alice' } })
})
it('身份被拒绝或更换之后没有账号', async () => {
const { profileStore, identityEvents } = await load()
profileStore.start()
profileStore.setAccount({ username: 'alice' })
await profileStore.registered({ username: 'alice' })
identityEvents.emit({ kind: 'rejected' })
expect(profileStore.state).toMatchObject({ member: null, account: null })
profileStore.setAccount({ username: 'alice' })
await profileStore.registered({ 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([])
})
})
+19 -8
View File
@@ -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,22 +27,33 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
return this.#loading
}
setAccount(account: Account): void {
// 本机已有的邮箱存进新账号,存失败只提示,不影响注册。
async registered(account: Account): Promise<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 email = changes.email ?? this.state.email
localStorage.setItem(emailKey, email)
this.setState({ member, email, failed: false })
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 })
}
return true
}
catch (error) {
// 输入框限制了昵称和邮箱的长度,INVALID_ARGUMENT 只剩去掉首尾空白之后为空的昵称。
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空,也不能只有空格。' }))
// 客户端不能按服务端的 message 区分是哪个字段不合格。
notificationStore.error(error instanceof IdentityRenewedError ? '个人资料没有保存,再保存一次。' : errorMessage(error, { INVALID_ARGUMENT: '昵称不能为空;邮箱要写成 name@example.com 这样的格式。' }))
return false
}
}
@@ -51,7 +62,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, failed: false })
this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', failed: false })
}
catch (error) {
// 成员 token 被拒绝时身份已经更换,新的成员由身份事件设置。
+6 -5
View File
@@ -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 { AccountSection } from './account-section'
import { AccountRow } from './account-section'
import { appearanceStore, fontSizes, themes } from './appearance-store'
import { performanceLevels, performanceStore } from './performance-store'
import { profileStore } from './profile-store'
@@ -69,6 +69,7 @@ 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)
@@ -178,16 +179,16 @@ export function SettingsPage() {
<div className="settings-page-body">
<fieldset className="settings-fields" disabled={saving}>
<div className="settings-content">
<AccountSection />
<section className="settings-section" aria-labelledby="settings-profile-title">
<h2 id="settings-profile-title" className="text-ui-lg">个人资料</h2>
<section className="settings-section" aria-labelledby="settings-account-title">
<h2 id="settings-account-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="只用来显示 Gravatar 头像,访问不到 Gravatar 时改用镜像 WeAvatar 或 Cravatar;服务器不会保存邮箱地址,留空就不显示头像。" field>
<SettingRow id="settings-email" title="邮箱" description={account ? '保存在账号里,用来显示头像。留空就不显示。' : '只保存在这台设备上,用来显示头像。留空就不显示。'} 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>
+3
View File
@@ -53,6 +53,7 @@
--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;
}
@@ -87,6 +88,7 @@
--color-success: #287144;
--color-toast: #fff;
--color-shadow: rgb(30 30 45 / 12%);
--glow-opacity: .5;
color-scheme: light;
}
@media (prefers-color-scheme: light) {
@@ -121,6 +123,7 @@
--color-success: #287144;
--color-toast: #fff;
--color-shadow: rgb(30 30 45 / 12%);
--glow-opacity: .5;
color-scheme: light;
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ export default defineConfig({
workbox: {
// 与 nginx 一致:只有页面路径返回 index.html,/api、/ws 和 /media 照常走网络。
// Workbox 拿路径加查询参数来匹配,nginx 只看路径,所以这里的规则末尾允许查询参数。
navigateFallbackAllowlist: [/^\/(?:admin|settings|history)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/],
navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/],
importScripts: ['notification-click.js'],
},
}),