From 8b941764b1d3231121f0c741b92cf267ac30f652 Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:08:57 +0800 Subject: [PATCH] feat: expose playlist sharing over HTTP --- internal/adapter/in/api/api.go | 5 + internal/adapter/in/api/playlist.go | 46 +++ .../testdata/http/playlist-imports.txtar | 6 +- .../testdata/http/playlist-shares.txtar | 269 ++++++++++++++++++ .../bootstrap/testdata/http/playlists.txtar | 10 +- protocol/decode.gen.go | 32 +++ protocol/idl.json | 66 ++++- protocol/openapi.gen.json | 260 ++++++++++++++++- protocol/types.gen.go | 33 ++- web/src/core/api/http.gen.ts | 16 +- web/src/core/realtime/protocol.gen.ts | 11 +- .../features/playlists/playlist-sort.test.ts | 2 +- .../playlists/playlists-store.test.ts | 6 +- 13 files changed, 743 insertions(+), 19 deletions(-) create mode 100644 internal/bootstrap/testdata/http/playlist-shares.txtar diff --git a/internal/adapter/in/api/api.go b/internal/adapter/in/api/api.go index 29dff26..741dddf 100644 --- a/internal/adapter/in/api/api.go +++ b/internal/adapter/in/api/api.go @@ -165,6 +165,10 @@ func New(s Services, metrics prometheus.Registerer, tracer trace.TracerProvider, srv.handle(mux, protocol.RouteClearMyListens, srv.clearMyListens) srv.handle(mux, protocol.RouteEnableListenRecording, srv.enableListenRecording) srv.handle(mux, protocol.RouteDisableListenRecording, srv.disableListenRecording) + srv.handle(mux, protocol.RouteShareMyPlaylist, srv.shareMyPlaylist) + srv.handle(mux, protocol.RouteUnshareMyPlaylist, srv.unshareMyPlaylist) + srv.handle(mux, protocol.RouteGetSharedPlaylist, srv.getSharedPlaylist) + srv.handle(mux, protocol.RouteCopySharedPlaylist, srv.copySharedPlaylist) mux.HandleFunc("GET /ws", srv.websocket) mux.HandleFunc("GET /api/room/events", srv.roomEvents) mux.HandleFunc("GET /media/{source}/{id}", srv.redirectMedia) @@ -352,6 +356,7 @@ var knownErrors = []struct { {catalog.ErrRateLimited, protocol.ErrorCodeRateLimited}, {errClientOutdated, protocol.ErrorCodeClientOutdated}, {errPanicked, protocol.ErrorCodeInternal}, + {port.ErrShareNotFound, protocol.ErrorCodeNotFound}, } func errorInfo(err error) (info protocol.ErrorInfo, expected bool) { diff --git a/internal/adapter/in/api/playlist.go b/internal/adapter/in/api/playlist.go index 4c2a632..39c8a2b 100644 --- a/internal/adapter/in/api/playlist.go +++ b/internal/adapter/in/api/playlist.go @@ -24,6 +24,7 @@ func (s *server) playlistDTO(p port.PlaylistRecord) protocol.MyPlaylist { CreatedAtMs: p.CreatedAt.UnixMilli(), UpdatedAtMs: p.UpdatedAt.UnixMilli(), CoverURL: cover, + ShareID: optional(p.ShareID), } } @@ -194,3 +195,48 @@ func (s *server) importMyPlatformPlaylist(ctx context.Context, c call) (any, err } return protocol.ImportMyPlatformPlaylistResponse{Playlist: s.playlistDTO(p), Imported: int64(n)}, nil } + +func (s *server) shareMyPlaylist(ctx context.Context, c call) (any, error) { + if _, err := protocol.DecodeShareMyPlaylistRequest(c.body); err != nil { + return nil, err + } + shareID, err := s.Playlists.Share(ctx, c.member.ID, c.r.PathValue("id")) + if err != nil { + return nil, err + } + return protocol.ShareMyPlaylistResponse{ShareID: shareID}, nil +} + +func (s *server) unshareMyPlaylist(ctx context.Context, c call) (any, error) { + return nil, s.Playlists.Unshare(ctx, c.member.ID, c.r.PathValue("id")) +} + +func (s *server) getSharedPlaylist(ctx context.Context, c call) (any, error) { + p, err := s.Playlists.Shared(ctx, c.r.PathValue("shareId")) + if err != nil { + return nil, err + } + items := make([]protocol.MyPlaylistItem, len(p.Items)) + for i, item := range p.Items { + items[i] = s.playlistItemDTO(item) + } + return protocol.SharedPlaylist{ + Name: p.Name, + Favorites: p.Favorites, + OwnerName: p.OwnerName, + OwnerAvatarHash: p.OwnerAvatarHash, + Items: items, + }, nil +} + +func (s *server) copySharedPlaylist(ctx context.Context, c call) (any, error) { + req, err := protocol.DecodeCopySharedPlaylistRequest(c.body) + if err != nil { + return nil, err + } + p, err := s.Playlists.CopyShared(ctx, c.member.ID, c.r.PathValue("shareId"), req.Name) + if err != nil { + return nil, err + } + return s.playlistDTO(p), nil +} diff --git a/internal/bootstrap/testdata/http/playlist-imports.txtar b/internal/bootstrap/testdata/http/playlist-imports.txtar index 0633da8..9bbf410 100644 --- a/internal/bootstrap/testdata/http/playlist-imports.txtar +++ b/internal/bootstrap/testdata/http/playlist-imports.txtar @@ -119,7 +119,7 @@ Authorization: Bearer $alice ]} -- response -- 201 -{"playlist": {"id": "$imported", "name": "文件里的歌单", "favorites": false, "trackCount": 3, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}, "imported": 3} +{"playlist": {"id": "$imported", "name": "文件里的歌单", "favorites": false, "trackCount": 3, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}, "imported": 3} -- request -- GET /api/me/playlists/$imported/items @@ -139,7 +139,7 @@ Authorization: Bearer $alice {"name": " ", "tracks": []} -- response -- 201 -{"playlist": {"id": "$empty", "name": "导入的歌单", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}, "imported": 0} +{"playlist": {"id": "$empty", "name": "导入的歌单", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}, "imported": 0} -- request -- POST /api/me/playlist-links @@ -166,7 +166,7 @@ Authorization: Bearer $alice {"name": "周杰伦精选", "source": "netease", "playlistId": "3778678"} -- response -- 201 -{"playlist": {"id": "$platform", "name": "周杰伦精选", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg"}, "imported": 2} +{"playlist": {"id": "$platform", "name": "周杰伦精选", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}, "imported": 2} -- request -- POST /api/me/playlists/platform-imports diff --git a/internal/bootstrap/testdata/http/playlist-shares.txtar b/internal/bootstrap/testdata/http/playlist-shares.txtar new file mode 100644 index 0000000..7d0181a --- /dev/null +++ b/internal/bootstrap/testdata/http/playlist-shares.txtar @@ -0,0 +1,269 @@ +歌单分享:已注册的账号可以分享自己的任何歌单,包括“我喜欢”;每次开启都生成新的链接,旧链接随即失效;不需要身份就能读取分享,响应里没有所有者的成员 ID 和用户名;已注册的成员可以把分享的歌单复制成自己的新歌单;停止分享、删除歌单之后分享失效。 + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$alice", "member": {"id": "$aliceId", "name": "$aliceName", "avatarHash": ""}} + +-- request -- +POST /api/account +Authorization: Bearer $alice + +{"username": "alice", "password": "correct horse"} +-- response -- +201 +{"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/playlists +Authorization: Bearer $alice +-- response -- +200 +{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": 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": "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 -- +POST /api/me/playlists/$favorites/share + +{} +-- response -- +401 +{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}} + +-- request -- +POST /api/me/playlists/$favorites/share +Authorization: Bearer $alice + +{} +-- response -- +200 +{"shareId": "$share"} + +-- 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", "shareId": "$share"}]} + +-- request -- +GET /api/shared-playlists/$share +-- response -- +200 +{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "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/share +Authorization: Bearer $alice + +{} +-- response -- +200 +{"shareId": "$newShare"} + +-- request -- +GET /api/shared-playlists/$share +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "分享已失效"}} + +-- request -- +GET /api/shared-playlists/$newShare +-- response -- +200 +{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "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 -- +GET /api/shared-playlists/not-a-share +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "分享已失效"}} + +-- request -- +POST /api/members + +{} +-- response -- +201 +{"memberToken": "$bob", "member": {"id": "$bobId", "name": "$bobName", "avatarHash": ""}} + +-- request -- +GET /api/shared-playlists/$newShare +Authorization: Bearer $bob +-- response -- +200 +{"name": "我喜欢", "favorites": true, "ownerName": "$aliceName", "ownerAvatarHash": "c160f8cc69a4f0bf2b0362752353d060", "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/shared-playlists/$newShare/copies + +{"name": "抄来的"} +-- response -- +401 +{"error": {"code": "UNAUTHORIZED", "message": "成员 token 无效"}} + +-- request -- +POST /api/shared-playlists/$newShare/copies +Authorization: Bearer $bob + +{"name": "抄来的"} +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}} + +-- request -- +POST /api/me/playlists/$favorites/share +Authorization: Bearer $bob + +{} +-- response -- +403 +{"error": {"code": "FORBIDDEN", "message": "注册账号之后才能使用歌单"}} + +-- request -- +POST /api/account +Authorization: Bearer $bob + +{"username": "bob", "password": "correct horse"} +-- response -- +201 +{"account": {"username": "bob"}} + +-- request -- +POST /api/me/playlists/$favorites/share +Authorization: Bearer $bob + +{} +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}} + +-- request -- +DELETE /api/me/playlists/$favorites/share +Authorization: Bearer $bob +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "歌单不存在"}} + +-- request -- +POST /api/shared-playlists/$newShare/copies +Authorization: Bearer $bob + +{"name": " "} +-- response -- +400 +{"error": {"code": "INVALID_ARGUMENT", "message": "name:去掉首尾空白后应有 1 到 50 个字符,其中至少一个看得见,不能包含控制字符"}} + +-- request -- +POST /api/shared-playlists/$newShare/copies +Authorization: Bearer $bob + +{"name": " 抄来的 "} +-- response -- +201 +{"id": "$copy", "name": "抄来的", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null} + +-- request -- +GET /api/me/playlists/$copy/items +Authorization: Bearer $bob +-- 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/shared-playlists/not-a-share/copies +Authorization: Bearer $bob + +{"name": "抄来的"} +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "分享已失效"}} + +-- request -- +DELETE /api/me/playlists/$favorites/share +Authorization: Bearer $alice +-- response -- +204 + +-- request -- +DELETE /api/me/playlists/$favorites/share +Authorization: Bearer $alice +-- response -- +204 + +-- request -- +GET /api/shared-playlists/$newShare +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "分享已失效"}} + +-- request -- +POST /api/shared-playlists/$newShare/copies +Authorization: Bearer $bob + +{"name": "抄来的"} +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "分享已失效"}} + +-- request -- +POST /api/me/playlists/$copy/share +Authorization: Bearer $bob + +{} +-- response -- +200 +{"shareId": "$copyShare"} + +-- request -- +DELETE /api/me/playlists/$copy +Authorization: Bearer $bob +-- response -- +204 + +-- request -- +GET /api/shared-playlists/$copyShare +-- response -- +404 +{"error": {"code": "NOT_FOUND", "message": "分享已失效"}} diff --git a/internal/bootstrap/testdata/http/playlists.txtar b/internal/bootstrap/testdata/http/playlists.txtar index be43217..8aeb521 100644 --- a/internal/bootstrap/testdata/http/playlists.txtar +++ b/internal/bootstrap/testdata/http/playlists.txtar @@ -38,7 +38,7 @@ GET /api/me/playlists Authorization: Bearer $alice -- response -- 200 -{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]} +{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}]} -- request -- POST /api/me/playlists/$favorites/items @@ -93,7 +93,7 @@ Authorization: Bearer $alice {"name": " 通勤 "} -- response -- 201 -{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null} +{"id": "$commute", "name": "通勤", "favorites": false, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null} -- request -- POST /api/me/playlists @@ -224,7 +224,7 @@ 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"}]} +{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null}, {"id": "$commute", "name": "上班路上", "favorites": false, "trackCount": 2, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/example.jpg", "shareId": null}]} -- request -- POST /api/me/playlist-memberships @@ -289,7 +289,7 @@ GET /api/me/playlists Authorization: Bearer $bob -- response -- 200 -{"items": [{"id": "$bobFavorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null}]} +{"items": [{"id": "$bobFavorites", "name": "我喜欢", "favorites": true, "trackCount": 0, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": null, "shareId": null}]} -- request -- POST /api/me/playlists/$bobFavorites/copies @@ -377,4 +377,4 @@ 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"}]} +{"items": [{"id": "$favorites", "name": "我喜欢", "favorites": true, "trackCount": 1, "createdAtMs": 1, "updatedAtMs": 1, "coverUrl": "https://p1.music.126.net/qilixiang.jpg", "shareId": null}]} diff --git a/protocol/decode.gen.go b/protocol/decode.gen.go index a3d7fc6..1615239 100644 --- a/protocol/decode.gen.go +++ b/protocol/decode.gen.go @@ -195,6 +195,14 @@ func DecodeEnableListenRecordingRequest(body []byte) (EnableListenRecordingReque return decodeEnableListenRecordingRequest(body, "") } +func DecodeShareMyPlaylistRequest(body []byte) (ShareMyPlaylistRequest, error) { + return decodeShareMyPlaylistRequest(body, "") +} + +func DecodeCopySharedPlaylistRequest(body []byte) (CopySharedPlaylistRequest, error) { + return decodeCopySharedPlaylistRequest(body, "") +} + func decodeAddMyPlaylistItemRequest(raw json.RawMessage, path string) (AddMyPlaylistItemRequest, error) { var v AddMyPlaylistItemRequest m, err := decodeObject(raw, path, "track") @@ -504,6 +512,24 @@ func decodeCopyMyPlaylistItemsRequest(raw json.RawMessage, path string) (CopyMyP return v, nil } +func decodeCopySharedPlaylistRequest(raw json.RawMessage, path string) (CopySharedPlaylistRequest, error) { + var v CopySharedPlaylistRequest + 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 decodeCreateMemberRequest(raw json.RawMessage, path string) (CreateMemberRequest, error) { var v CreateMemberRequest _, err := decodeObject(raw, path) @@ -1152,6 +1178,12 @@ func decodeSettingsSetPlayModeRequest(raw json.RawMessage, path string) (Setting return v, nil } +func decodeShareMyPlaylistRequest(raw json.RawMessage, path string) (ShareMyPlaylistRequest, error) { + var v ShareMyPlaylistRequest + _, err := decodeObject(raw, path) + return v, err +} + func decodeSource(raw json.RawMessage, path string) (Source, error) { s, err := decodeString(raw, path) if err != nil { diff --git a/protocol/idl.json b/protocol/idl.json index a756bcb..11b4168 100644 --- a/protocol/idl.json +++ b/protocol/idl.json @@ -186,9 +186,10 @@ "trackCount": {"type": "integer"}, "createdAtMs": {"type": "integer"}, "updatedAtMs": {"type": "integer"}, - "coverUrl": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null"} + "coverUrl": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "歌单里排在最前、有封面的那首歌的封面;没有时为 null"}, + "shareId": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "分享链接 /s/ 中的 ID;没有分享时为 null"} }, - "required": ["id", "name", "favorites", "trackCount", "createdAtMs", "updatedAtMs", "coverUrl"] + "required": ["id", "name", "favorites", "trackCount", "createdAtMs", "updatedAtMs", "coverUrl", "shareId"] }, "MyPlaylistItem": { "type": "object", @@ -362,6 +363,18 @@ "heardAtMs": {"type": "integer", "description": "最近一次听到这首歌时它的开播时间"} }, "required": ["track", "meta", "heardAtMs"] + }, + "SharedPlaylist": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string"}, + "favorites": {"type": "boolean", "description": "是否是“我喜欢”"}, + "ownerName": {"type": "string", "description": "所有者的昵称"}, + "ownerAvatarHash": {"type": "string", "description": "所有者邮箱的 MD5,未设置邮箱时为空字符串"}, + "items": {"type": "array", "items": {"$ref": "#/$defs/MyPlaylistItem"}, "description": "新加入的在前"} + }, + "required": ["name", "favorites", "ownerName", "ownerAvatarHash", "items"] } }, "x-ws": { @@ -1624,6 +1637,55 @@ "path": "/api/me/listen-recording", "auth": "member", "status": 204 + }, + { + "name": "shareMyPlaylist", + "description": "开启分享或重新生成分享链接:每次都生成新的 shareId,之前的链接随即失效;“我喜欢”也能分享", + "method": "POST", + "path": "/api/me/playlists/{id}/share", + "auth": "member", + "request": {"type": "object", "additionalProperties": false, "properties": {}}, + "response": { + "type": "object", + "additionalProperties": false, + "properties": { + "shareId": {"type": "string"} + }, + "required": ["shareId"] + } + }, + { + "name": "unshareMyPlaylist", + "description": "停止分享:分享链接随即失效;没有分享时也算成功", + "method": "DELETE", + "path": "/api/me/playlists/{id}/share", + "auth": "member", + "status": 204 + }, + { + "name": "getSharedPlaylist", + "description": "读取分享的歌单,不需要身份;不返回所有者的成员 ID 和用户名;停止分享、重新生成链接或删除歌单之后返回 NOT_FOUND", + "method": "GET", + "path": "/api/shared-playlists/{shareId}", + "auth": "none", + "response": {"$ref": "#/$defs/SharedPlaylist"} + }, + { + "name": "copySharedPlaylist", + "description": "把分享的歌单复制成自己的新歌单,带上全部曲目和顺序;新歌单受“最多 50 个”的限制;分享已失效时返回 NOT_FOUND", + "method": "POST", + "path": "/api/shared-playlists/{shareId}/copies", + "auth": "member", + "status": 201, + "request": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"$ref": "#/$defs/PlaylistName"} + }, + "required": ["name"] + }, + "response": {"$ref": "#/$defs/MyPlaylist"} } ] } diff --git a/protocol/openapi.gen.json b/protocol/openapi.gen.json index 5f91d75..d65d0c1 100644 --- a/protocol/openapi.gen.json +++ b/protocol/openapi.gen.json @@ -475,6 +475,20 @@ ], "type": "object" }, + "CopySharedPlaylistRequest": { + "additionalProperties": false, + "properties": { + "name": { + "description": "歌单名:去掉首尾空白后 1 到 50 个字符", + "maxLength": 200, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "CreateMemberRequest": { "additionalProperties": false, "type": "object" @@ -1087,6 +1101,17 @@ "name": { "type": "string" }, + "shareId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "分享链接 /s/ 中的 ID;没有分享时为 null" + }, "trackCount": { "type": "integer" }, @@ -1101,7 +1126,8 @@ "trackCount", "createdAtMs", "updatedAtMs", - "coverUrl" + "coverUrl", + "shareId" ], "type": "object" }, @@ -1700,6 +1726,57 @@ ], "type": "object" }, + "ShareMyPlaylistRequest": { + "additionalProperties": false, + "type": "object" + }, + "ShareMyPlaylistResponse": { + "additionalProperties": false, + "properties": { + "shareId": { + "type": "string" + } + }, + "required": [ + "shareId" + ], + "type": "object" + }, + "SharedPlaylist": { + "additionalProperties": false, + "properties": { + "favorites": { + "description": "是否是“我喜欢”", + "type": "boolean" + }, + "items": { + "description": "新加入的在前", + "items": { + "$ref": "#/components/schemas/MyPlaylistItem" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "ownerAvatarHash": { + "description": "所有者邮箱的 MD5,未设置邮箱时为空字符串", + "type": "string" + }, + "ownerName": { + "description": "所有者的昵称", + "type": "string" + } + }, + "required": [ + "name", + "favorites", + "ownerName", + "ownerAvatarHash", + "items" + ], + "type": "object" + }, "Source": { "enum": [ "netease", @@ -1956,7 +2033,7 @@ }, "info": { "title": "Alisten", - "version": "ae9a632d648f3840" + "version": "cbfa923a9cbe9f0a" }, "openapi": "3.1.0", "paths": { @@ -3811,6 +3888,93 @@ "summary": "把若干首歌移出歌单,一次 1 到 1000 首;不在歌单里的忽略" } }, + "/api/me/playlists/{id}/share": { + "delete": { + "operationId": "unshareMyPlaylist", + "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": "停止分享:分享链接随即失效;没有分享时也算成功" + }, + "post": { + "operationId": "shareMyPlaylist", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareMyPlaylistRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShareMyPlaylistResponse" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [ + { + "memberToken": [] + } + ], + "summary": "开启分享或重新生成分享链接:每次都生成新的 shareId,之前的链接随即失效;“我喜欢”也能分享" + } + }, "/api/members": { "post": { "operationId": "createMember", @@ -4608,6 +4772,98 @@ "summary": "退出登录:发出请求的成员 token 随即失效" } }, + "/api/shared-playlists/{shareId}": { + "get": { + "operationId": "getSharedPlaylist", + "parameters": [ + { + "in": "path", + "name": "shareId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedPlaylist" + } + } + }, + "description": "成功" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "失败" + } + }, + "security": [], + "summary": "读取分享的歌单,不需要身份;不返回所有者的成员 ID 和用户名;停止分享、重新生成链接或删除歌单之后返回 NOT_FOUND" + } + }, + "/api/shared-playlists/{shareId}/copies": { + "post": { + "operationId": "copySharedPlaylist", + "parameters": [ + { + "in": "path", + "name": "shareId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CopySharedPlaylistRequest" + } + } + }, + "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 个”的限制;分享已失效时返回 NOT_FOUND" + } + }, "/api/site": { "get": { "operationId": "getSite", diff --git a/protocol/types.gen.go b/protocol/types.gen.go index d367074..d9b0d42 100644 --- a/protocol/types.gen.go +++ b/protocol/types.gen.go @@ -4,7 +4,7 @@ package protocol import "encoding/json" -const Version = "ae9a632d648f3840" +const Version = "cbfa923a9cbe9f0a" type APIKey struct { Key string `json:"key"` @@ -181,6 +181,10 @@ type CopyMyPlaylistItemsResponse struct { Added int64 `json:"added"` } +type CopySharedPlaylistRequest struct { + Name string `json:"name"` +} + type CreateMemberRequest struct{} type CreateMemberResponse struct { @@ -437,6 +441,7 @@ type MyPlaylist struct { Favorites bool `json:"favorites"` ID string `json:"id"` Name string `json:"name"` + ShareID *string `json:"shareId"` TrackCount int64 `json:"trackCount"` UpdatedAtMs int64 `json:"updatedAtMs"` } @@ -742,6 +747,28 @@ type SettingsUpdatedEvent struct { PlayMode PlayMode `json:"playMode"` } +type ShareMyPlaylistRequest struct{} + +type ShareMyPlaylistResponse struct { + ShareID string `json:"shareId"` +} + +type SharedPlaylist struct { + Favorites bool `json:"favorites"` + Items []MyPlaylistItem `json:"items"` + Name string `json:"name"` + OwnerAvatarHash string `json:"ownerAvatarHash"` + OwnerName string `json:"ownerName"` +} + +func (v SharedPlaylist) MarshalJSON() ([]byte, error) { + type plain SharedPlaylist + if v.Items == nil { + v.Items = []MyPlaylistItem{} + } + return json.Marshal(plain(v)) +} + type Source string const ( @@ -895,6 +922,10 @@ var ( RouteClearMyListens = Route{Pattern: "DELETE /api/me/listens", Auth: AuthMember, Status: 204} RouteEnableListenRecording = Route{Pattern: "PUT /api/me/listen-recording", Auth: AuthMember, Status: 204} RouteDisableListenRecording = Route{Pattern: "DELETE /api/me/listen-recording", Auth: AuthMember, Status: 204} + RouteShareMyPlaylist = Route{Pattern: "POST /api/me/playlists/{id}/share", Auth: AuthMember, Status: 200} + RouteUnshareMyPlaylist = Route{Pattern: "DELETE /api/me/playlists/{id}/share", Auth: AuthMember, Status: 204} + RouteGetSharedPlaylist = Route{Pattern: "GET /api/shared-playlists/{shareId}", Auth: AuthNone, Status: 200} + RouteCopySharedPlaylist = Route{Pattern: "POST /api/shared-playlists/{shareId}/copies", Auth: AuthMember, Status: 201} ) var RoomRoutes = []RoomRoute{ diff --git a/web/src/core/api/http.gen.ts b/web/src/core/api/http.gen.ts index 0e0fe95..b61682e 100644 --- a/web/src/core/api/http.gen.ts +++ b/web/src/core/api/http.gen.ts @@ -1,6 +1,6 @@ // Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. -import type { APIKey, Account, AdminAccount, ErrorInfo, ImportedTrack, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen' +import type { APIKey, Account, AdminAccount, ErrorInfo, ImportedTrack, Member, MyListen, MyPlaylist, MyPlaylistItem, QQLoginType, QRLogin, QRLoginResult, RoomListing, SharedPlaylist, Source, SourceAccount, StoredMedia, Track } from '../realtime/protocol.gen' export interface AddMyPlaylistItemRequest { track: Track @@ -118,6 +118,10 @@ export interface CopyMyPlaylistItemsResponse { added: number } +export interface CopySharedPlaylistRequest { + name: string +} + export type CreateMemberRequest = Record export interface CreateMemberResponse { @@ -253,6 +257,12 @@ export interface ResolveMyPlaylistLinkResponse { source: Source } +export type ShareMyPlaylistRequest = Record + +export interface ShareMyPlaylistResponse { + shareId: string +} + export interface UpdateMeRequest { email?: string name?: string @@ -334,5 +344,9 @@ export function httpClient(send: HttpSend) { clearMyListens: () => send({ method: 'DELETE', path: '/api/me/listens', auth: 'member' }), enableListenRecording: () => send({ method: 'PUT', path: '/api/me/listen-recording', auth: 'member', body: {} }), disableListenRecording: () => send({ method: 'DELETE', path: '/api/me/listen-recording', auth: 'member' }), + shareMyPlaylist: (id: string) => send({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/share`, auth: 'member', body: {} }), + unshareMyPlaylist: (id: string) => send({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}/share`, auth: 'member' }), + getSharedPlaylist: (shareId: string) => send({ method: 'GET', path: `/api/shared-playlists/${encodeURIComponent(shareId)}`, auth: 'none' }), + copySharedPlaylist: (shareId: string, body: CopySharedPlaylistRequest) => send({ method: 'POST', path: `/api/shared-playlists/${encodeURIComponent(shareId)}/copies`, auth: 'member', body }), } } diff --git a/web/src/core/realtime/protocol.gen.ts b/web/src/core/realtime/protocol.gen.ts index 0bb87cf..8047f9a 100644 --- a/web/src/core/realtime/protocol.gen.ts +++ b/web/src/core/realtime/protocol.gen.ts @@ -1,6 +1,6 @@ // Code generated by protocolgen from protocol/idl.json; DO NOT EDIT. -export const PROTOCOL_VERSION = 'ae9a632d648f3840' +export const PROTOCOL_VERSION = 'cbfa923a9cbe9f0a' export interface APIKey { key: string @@ -94,6 +94,7 @@ export interface MyPlaylist { favorites: boolean id: string name: string + shareId: string | null trackCount: number updatedAtMs: number } @@ -284,6 +285,14 @@ export interface SettingsUpdatedEvent { playMode: PlayMode } +export interface SharedPlaylist { + favorites: boolean + items: MyPlaylistItem[] + name: string + ownerAvatarHash: string + ownerName: string +} + export type Source = 'netease' | 'qq' | 'bilibili' | 'link' export interface SourceAccount { diff --git a/web/src/features/playlists/playlist-sort.test.ts b/web/src/features/playlists/playlist-sort.test.ts index e323ac0..87db8cd 100644 --- a/web/src/features/playlists/playlist-sort.test.ts +++ b/web/src/features/playlists/playlist-sort.test.ts @@ -4,7 +4,7 @@ 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 } + return { id, name, favorites, trackCount, createdAtMs, updatedAtMs, coverUrl: null, shareId: null } } const favorites = playlist('p0', '我喜欢', 3, 50, 50, true) diff --git a/web/src/features/playlists/playlists-store.test.ts b/web/src/features/playlists/playlists-store.test.ts index b475353..4f6e104 100644 --- a/web/src/features/playlists/playlists-store.test.ts +++ b/web/src/features/playlists/playlists-store.test.ts @@ -25,15 +25,15 @@ class FakeStorage { } } -const favorites = { id: 'p1', name: '我喜欢', favorites: true, trackCount: 1, createdAtMs: 1, updatedAtMs: 1, coverUrl: null } +const favorites = { id: 'p1', name: '我喜欢', favorites: true, trackCount: 1, createdAtMs: 1, updatedAtMs: 1, coverUrl: null, shareId: 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 commute = { id: 'p2', name: '通勤', favorites: false, trackCount: 0, createdAtMs: 1, updatedAtMs: 1, coverUrl: null, shareId: null } const meta = { title: '晴天', artist: '周杰伦', album: '叶惠美', coverUrl: '', pageUrl: '', durationMs: 0 } const notReady = '这首歌还在准备,准备好之后再保存' const full = '一个歌单最多 1000 首歌' const changed = '歌单已经变了,刷新之后再试' -const imported = { id: 'p3', name: '导入的歌单', favorites: false, trackCount: 2, createdAtMs: 2, updatedAtMs: 2, coverUrl: null } +const imported = { id: 'p3', name: '导入的歌单', favorites: false, trackCount: 2, createdAtMs: 2, updatedAtMs: 2, coverUrl: null, shareId: null } const fileTrack = { track: sunny, title: '晴天', durationMs: 269000 } let requests: string[]