feat: expose playlist sharing over HTTP

This commit is contained in:
DiheChen committed 2026-11-11 00:08:57 +08:00
1 parent eed25578c7
commit 8b941764b1
13 files changed
+743 -19

No files matched your search

+5
View File
@@ -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) {
+46
View File
@@ -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
}
+3 -3
View File
@@ -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
+269
View File
@@ -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": "分享已失效"}}
+5 -5
View File
@@ -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}]}
+32
View File
@@ -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 {
+64 -2
View File
@@ -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/<shareId> 中的 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"}
}
]
}
+258 -2
View File
@@ -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/<shareId> 中的 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",
+32 -1
View File
@@ -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{
+15 -1
View File
@@ -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<string, never>
export interface CreateMemberResponse {
@@ -253,6 +257,12 @@ export interface ResolveMyPlaylistLinkResponse {
source: Source
}
export type ShareMyPlaylistRequest = Record<string, never>
export interface ShareMyPlaylistResponse {
shareId: string
}
export interface UpdateMeRequest {
email?: string
name?: string
@@ -334,5 +344,9 @@ export function httpClient(send: HttpSend) {
clearMyListens: () => send<void>({ method: 'DELETE', path: '/api/me/listens', auth: 'member' }),
enableListenRecording: () => send<void>({ method: 'PUT', path: '/api/me/listen-recording', auth: 'member', body: {} }),
disableListenRecording: () => send<void>({ method: 'DELETE', path: '/api/me/listen-recording', auth: 'member' }),
shareMyPlaylist: (id: string) => send<ShareMyPlaylistResponse>({ method: 'POST', path: `/api/me/playlists/${encodeURIComponent(id)}/share`, auth: 'member', body: {} }),
unshareMyPlaylist: (id: string) => send<void>({ method: 'DELETE', path: `/api/me/playlists/${encodeURIComponent(id)}/share`, auth: 'member' }),
getSharedPlaylist: (shareId: string) => send<SharedPlaylist>({ method: 'GET', path: `/api/shared-playlists/${encodeURIComponent(shareId)}`, auth: 'none' }),
copySharedPlaylist: (shareId: string, body: CopySharedPlaylistRequest) => send<MyPlaylist>({ method: 'POST', path: `/api/shared-playlists/${encodeURIComponent(shareId)}/copies`, auth: 'member', body }),
}
}
+10 -1
View File
@@ -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 {
@@ -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)
@@ -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[]